From db7a34e4f392c072ccd26bde39c4337d59bbc814 Mon Sep 17 00:00:00 2001 From: Viet Tran Date: Sun, 29 Mar 2026 18:01:08 +0700 Subject: [PATCH] feat: standalone Import & Export page with Teams, Agents, Skills & MCP Refactors Agent Transfer into standalone /import-export page with 3 scope tabs. Teams: auto-bundle member agents (config, context files, memory, KG, cron, profiles, workspace). Agents: section picker with presets, merge mode support. Skills & MCP: custom skills + MCP server configs (stripped secrets), conflict detection. Security: scopeClauseAlias for JOINs, tenant scope on all queries, zip bomb protection, adminMiddleware on import routes. DRY: shared storeExportToken + readTarGzEntries helpers. i18n: complete en/vi/zh coverage. --- cmd/gateway_http_handlers.go | 4 + internal/http/agents.go | 36 +- internal/http/agents_export.go | 756 ++++++++++++++++++ internal/http/agents_export_team.go | 142 ++++ internal/http/agents_import.go | 622 ++++++++++++++ internal/http/agents_import_helpers.go | 295 +++++++ internal/http/agents_import_team.go | 252 ++++++ internal/http/agents_workspace.go | 133 +++ internal/http/mcp.go | 6 + internal/http/mcp_export.go | 152 ++++ internal/http/mcp_import.go | 163 ++++ internal/http/skills.go | 6 + internal/http/skills_export.go | 190 +++++ internal/http/skills_import.go | 243 ++++++ internal/http/sse.go | 42 + internal/http/teams_export.go | 528 ++++++++++++ internal/http/teams_import.go | 426 ++++++++++ internal/store/pg/agents_export_queries.go | 496 ++++++++++++ .../store/pg/agents_export_team_queries.go | 451 +++++++++++ .../store/pg/agents_export_team_standalone.go | 300 +++++++ internal/store/pg/mcp_export_queries.go | 218 +++++ internal/store/pg/skills_export_queries.go | 200 +++++ ui/web/src/api/http-client.ts | 5 + ui/web/src/components/layout/sidebar.tsx | 2 + .../components/shared/operation-progress.tsx | 78 ++ ui/web/src/hooks/use-sse-progress.ts | 220 +++++ ui/web/src/i18n/index.ts | 8 +- ui/web/src/i18n/locales/en/agents.json | 68 ++ ui/web/src/i18n/locales/en/import-export.json | 131 +++ ui/web/src/i18n/locales/en/sidebar.json | 1 + ui/web/src/i18n/locales/vi/agents.json | 68 ++ ui/web/src/i18n/locales/vi/import-export.json | 131 +++ ui/web/src/i18n/locales/vi/sidebar.json | 1 + ui/web/src/i18n/locales/zh/agents.json | 68 ++ ui/web/src/i18n/locales/zh/import-export.json | 131 +++ ui/web/src/i18n/locales/zh/sidebar.json | 1 + ui/web/src/lib/constants.ts | 1 + ui/web/src/pages/agents/agents-page.tsx | 13 +- .../import-export/agent-export-panel.tsx | 144 ++++ .../import-export/agent-import-panel.tsx | 203 +++++ .../capabilities-export-panel.tsx | 188 +++++ .../capabilities-import-panel.tsx | 261 ++++++ .../import-export/hooks/use-agent-export.ts | 75 ++ .../import-export/hooks/use-agent-import.ts | 102 +++ .../hooks/use-capabilities-export.ts | 98 +++ .../hooks/use-capabilities-import.ts | 43 + .../import-export/hooks/use-team-export.ts | 70 ++ .../import-export/hooks/use-team-import.ts | 23 + .../import-export/import-export-page.tsx | 111 +++ .../pages/import-export/section-picker.tsx | 99 +++ .../pages/import-export/team-export-panel.tsx | 170 ++++ .../pages/import-export/team-import-panel.tsx | 110 +++ ui/web/src/routes.tsx | 4 + 53 files changed, 8280 insertions(+), 9 deletions(-) create mode 100644 internal/http/agents_export.go create mode 100644 internal/http/agents_export_team.go create mode 100644 internal/http/agents_import.go create mode 100644 internal/http/agents_import_helpers.go create mode 100644 internal/http/agents_import_team.go create mode 100644 internal/http/agents_workspace.go create mode 100644 internal/http/mcp_export.go create mode 100644 internal/http/mcp_import.go create mode 100644 internal/http/skills_export.go create mode 100644 internal/http/skills_import.go create mode 100644 internal/http/sse.go create mode 100644 internal/http/teams_export.go create mode 100644 internal/http/teams_import.go create mode 100644 internal/store/pg/agents_export_queries.go create mode 100644 internal/store/pg/agents_export_team_queries.go create mode 100644 internal/store/pg/agents_export_team_standalone.go create mode 100644 internal/store/pg/mcp_export_queries.go create mode 100644 internal/store/pg/skills_export_queries.go create mode 100644 ui/web/src/components/shared/operation-progress.tsx create mode 100644 ui/web/src/hooks/use-sse-progress.ts create mode 100644 ui/web/src/i18n/locales/en/import-export.json create mode 100644 ui/web/src/i18n/locales/vi/import-export.json create mode 100644 ui/web/src/i18n/locales/zh/import-export.json create mode 100644 ui/web/src/pages/import-export/agent-export-panel.tsx create mode 100644 ui/web/src/pages/import-export/agent-import-panel.tsx create mode 100644 ui/web/src/pages/import-export/capabilities-export-panel.tsx create mode 100644 ui/web/src/pages/import-export/capabilities-import-panel.tsx create mode 100644 ui/web/src/pages/import-export/hooks/use-agent-export.ts create mode 100644 ui/web/src/pages/import-export/hooks/use-agent-import.ts create mode 100644 ui/web/src/pages/import-export/hooks/use-capabilities-export.ts create mode 100644 ui/web/src/pages/import-export/hooks/use-capabilities-import.ts create mode 100644 ui/web/src/pages/import-export/hooks/use-team-export.ts create mode 100644 ui/web/src/pages/import-export/hooks/use-team-import.ts create mode 100644 ui/web/src/pages/import-export/import-export-page.tsx create mode 100644 ui/web/src/pages/import-export/section-picker.tsx create mode 100644 ui/web/src/pages/import-export/team-export-panel.tsx create mode 100644 ui/web/src/pages/import-export/team-import-panel.tsx diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index 1d587f60..fbd523c1 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -26,6 +26,8 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir summoner = httpapi.NewAgentSummoner(stores.Agents, providerReg, msgBus) } agentsH = httpapi.NewAgentsHandler(stores.Agents, stores.Providers, providerReg, stores.DB, stores.Tracing, defaultWorkspace, msgBus, summoner, isOwner) + agentsH.SetImportStores(stores.Memory, stores.KnowledgeGraph) + agentsH.SetDataDir(dataDir) } if stores != nil && stores.Skills != nil { @@ -33,6 +35,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir dirs := manageStore.Dirs() if len(dirs) > 0 { skillsH = httpapi.NewSkillsHandler(manageStore, dirs[0], dataDir, bundledSkillsDir, msgBus, stores.SkillTenantCfgs, stores.Tenants) + skillsH.SetDB(stores.DB) } } } @@ -43,6 +46,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir if stores != nil && stores.MCP != nil { mcpH = httpapi.NewMCPHandler(stores.MCP, msgBus, mcpToolLister) + mcpH.SetDB(stores.DB) } var mcpUserCredsH *httpapi.MCPUserCredentialsHandler if stores != nil && stores.MCP != nil { diff --git a/internal/http/agents.go b/internal/http/agents.go index ac62773a..40fcf7c2 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -27,10 +27,13 @@ type AgentsHandler struct { providerReg *providers.Registry db *sql.DB tracingStore store.TracingStore - defaultWorkspace string // default workspace path template (e.g. "~/.goclaw/workspace") - msgBus *bus.MessageBus // for cache invalidation events (nil = no events) - summoner *AgentSummoner // LLM-based agent setup (nil = disabled) - isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured) + memoryStore store.MemoryStore // for import (nil = disabled) + kgStore store.KnowledgeGraphStore // for import (nil = disabled) + defaultWorkspace string // default workspace path template (e.g. "~/.goclaw/workspace") + dataDir string // resolved data directory (e.g. "~/.goclaw/data") — for team workspace export + msgBus *bus.MessageBus // for cache invalidation events (nil = no events) + summoner *AgentSummoner // LLM-based agent setup (nil = disabled) + isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured) } // NewAgentsHandler creates a handler for agent management endpoints. @@ -49,6 +52,17 @@ func NewAgentsHandler(agents store.AgentStore, providers store.ProviderStore, pr } } +// SetDataDir sets the resolved data directory used for team workspace paths. +func (h *AgentsHandler) SetDataDir(dataDir string) { + h.dataDir = dataDir +} + +// SetImportStores attaches optional stores needed for agent import. +func (h *AgentsHandler) SetImportStores(mem store.MemoryStore, kg store.KnowledgeGraphStore) { + h.memoryStore = mem + h.kgStore = kg +} + // isOwnerUser checks if the given user ID is a system owner. func (h *AgentsHandler) isOwnerUser(userID string) bool { return userID != "" && h.isOwner != nil && h.isOwner(userID) @@ -80,6 +94,20 @@ func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) { // Agent operations (admin+) mux.HandleFunc("POST /v1/agents/{id}/regenerate", h.adminMiddleware(h.handleRegenerate)) mux.HandleFunc("POST /v1/agents/{id}/resummon", h.adminMiddleware(h.handleResummon)) + // Export (agent owner or system owner) + mux.HandleFunc("GET /v1/agents/{id}/export/preview", h.authMiddleware(h.handleExportPreview)) + mux.HandleFunc("GET /v1/agents/{id}/export", h.authMiddleware(h.handleExport)) + mux.HandleFunc("GET /v1/agents/{id}/export/download/{token}", h.authMiddleware(h.handleExportDownload)) + // Shared download route for all export types (skills, MCP, teams use same token map) + mux.HandleFunc("GET /v1/export/download/{token}", h.authMiddleware(h.handleExportDownload)) + // Import (admin only — system owner or tenant admin) + mux.HandleFunc("POST /v1/agents/import/preview", h.adminMiddleware(h.handleImportPreview)) + mux.HandleFunc("POST /v1/agents/import", h.adminMiddleware(h.handleImport)) + mux.HandleFunc("POST /v1/agents/{id}/import", h.adminMiddleware(h.handleMergeImport)) + // Team export/import (system owner only) + mux.HandleFunc("GET /v1/teams/{id}/export/preview", h.adminMiddleware(h.handleTeamExportPreview)) + mux.HandleFunc("GET /v1/teams/{id}/export", h.adminMiddleware(h.handleTeamExport)) + mux.HandleFunc("POST /v1/teams/import", h.adminMiddleware(h.handleTeamImport)) // Read-only (viewer+) mux.HandleFunc("GET /v1/agents/{id}/codex-pool-activity", h.authMiddleware(h.handleCodexPoolActivity)) mux.HandleFunc("GET /v1/agents/{id}/instances", h.authMiddleware(h.handleListInstances)) diff --git a/internal/http/agents_export.go b/internal/http/agents_export.go new file mode 100644 index 00000000..11faffea --- /dev/null +++ b/internal/http/agents_export.go @@ -0,0 +1,756 @@ +package http + +import ( + "archive/tar" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +const maxExportSize = 500 << 20 // 500MB + +// exportToken holds a short-lived reference to a completed export temp file. +type exportToken struct { + agentID string + userID string // creator — verified on download + filePath string + fileName string + expiresAt time.Time +} + +var ( + exportTokenMu sync.Mutex + exportTokens = map[string]*exportToken{} + exportSweepOnce sync.Once +) + +// storeExportToken creates a short-lived token referencing a temp export file. +// Starts a background sweep goroutine on first call (once per process). +func storeExportToken(entityID, userID, filePath, fileName string) string { + exportSweepOnce.Do(func() { + go sweepExportTokens() + }) + token := uuid.Must(uuid.NewV7()).String() + entry := &exportToken{ + agentID: entityID, + userID: userID, + filePath: filePath, + fileName: fileName, + expiresAt: time.Now().Add(5 * time.Minute), + } + exportTokenMu.Lock() + exportTokens[token] = entry + exportTokenMu.Unlock() + return token +} + +// sweepExportTokens runs every 60s, removes expired tokens and their temp files. +func sweepExportTokens() { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + for range ticker.C { + now := time.Now() + exportTokenMu.Lock() + for tok, e := range exportTokens { + if now.After(e.expiresAt) { + os.Remove(e.filePath) //nolint:errcheck + delete(exportTokens, tok) + } + } + exportTokenMu.Unlock() + } +} + +// ExportManifest describes the archive contents. +type ExportManifest struct { + Version int `json:"version"` + Format string `json:"format"` + ExportedAt string `json:"exported_at"` + ExportedBy string `json:"exported_by"` + AgentKey string `json:"agent_key"` + AgentID string `json:"agent_id"` + Sections map[string]interface{} `json:"sections"` +} + +// KGEntityExport is a portable KG entity (no internal UUID). +type KGEntityExport struct { + ExternalID string `json:"external_id"` + UserID string `json:"user_id,omitempty"` + Name string `json:"name"` + EntityType string `json:"entity_type"` + Description string `json:"description,omitempty"` + Properties map[string]string `json:"properties,omitempty"` + Confidence float64 `json:"confidence"` +} + +// KGRelationExport is a portable KG relation using external IDs. +type KGRelationExport struct { + SourceExternalID string `json:"source_external_id"` + TargetExternalID string `json:"target_external_id"` + UserID string `json:"user_id,omitempty"` + RelationType string `json:"relation_type"` + Confidence float64 `json:"confidence"` + Properties map[string]string `json:"properties,omitempty"` +} + +// MemoryExport is a portable memory document. +type MemoryExport struct { + Path string `json:"path"` + Content string `json:"content"` + UserID string `json:"user_id,omitempty"` +} + +// handleExportPreview returns lightweight counts per exportable section. +func (h *AgentsHandler) handleExportPreview(w http.ResponseWriter, r *http.Request) { + userID := store.UserIDFromContext(r.Context()) + locale := store.LocaleFromContext(r.Context()) + + ag, status, err := h.lookupAccessibleAgent(r) + if err != nil { + writeError(w, status, protocol.ErrNotFound, err.Error()) + return + } + if !h.canExport(ag, userID) { + writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "agent")) + return + } + + counts, err := pg.ExportPreviewCounts(r.Context(), h.db, ag.ID) + if err != nil { + slog.Error("agents.export.preview", "agent_id", ag.ID, "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "failed to fetch preview counts")) + return + } + + // Count workspace files (filesystem, not DB) + var workspaceFiles int + if ag.Workspace != "" { + wsPath := config.ExpandHome(ag.Workspace) + if info, statErr := os.Stat(wsPath); statErr == nil && info.IsDir() { + filepath.WalkDir(wsPath, func(_ string, d fs.DirEntry, _ error) error { //nolint:errcheck + if d.IsDir() || strings.HasPrefix(d.Name(), ".") || d.Type()&fs.ModeSymlink != 0 { + return nil + } + if fi, err := d.Info(); err == nil && fi.Size() <= maxWorkspaceFileSize { + workspaceFiles++ + } + return nil + }) + } + } + + type previewResponse struct { + *pg.ExportPreview + WorkspaceFiles int `json:"workspace_files"` + } + writeJSON(w, http.StatusOK, previewResponse{ExportPreview: counts, WorkspaceFiles: workspaceFiles}) +} + +// handleExport dispatches to SSE streaming or direct download based on ?stream= param. +func (h *AgentsHandler) handleExport(w http.ResponseWriter, r *http.Request) { + userID := store.UserIDFromContext(r.Context()) + locale := store.LocaleFromContext(r.Context()) + + ag, status, err := h.lookupAccessibleAgent(r) + if err != nil { + writeError(w, status, protocol.ErrNotFound, err.Error()) + return + } + if !h.canExport(ag, userID) { + writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "agent")) + return + } + + sections := parseExportSections(r.URL.Query().Get("sections")) + stream := r.URL.Query().Get("stream") == "true" + + if stream { + h.handleExportSSE(w, r, ag, sections) + } else { + h.handleExportDirect(w, r, ag, sections) + } +} + +// handleExportDownload serves a previously-prepared export archive by token. +func (h *AgentsHandler) handleExportDownload(w http.ResponseWriter, r *http.Request) { + userID := store.UserIDFromContext(r.Context()) + locale := store.LocaleFromContext(r.Context()) + + token := r.PathValue("token") + if token == "" { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "token")) + return + } + + exportTokenMu.Lock() + entry, ok := exportTokens[token] + if ok && time.Now().After(entry.expiresAt) { + delete(exportTokens, token) + ok = false + } + exportTokenMu.Unlock() + + if !ok { + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "export token", token)) + return + } + + // Verify token belongs to requesting user (or system owner) + if entry.userID != userID && !h.isOwnerUser(userID) { + writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "export download")) + return + } + + f, err := os.Open(entry.filePath) + if err != nil { + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError)) + return + } + defer f.Close() + + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, entry.fileName)) + io.Copy(w, f) //nolint:errcheck +} + +// handleExportSSE streams build progress as SSE events then sends a download token on completion. +func (h *AgentsHandler) handleExportSSE(w http.ResponseWriter, r *http.Request, ag *store.AgentData, sections map[string]bool) { + flusher := initSSE(w) + if flusher == nil { + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, "streaming not supported") + return + } + + tmpFile, err := os.CreateTemp("", "goclaw-export-*.tar.gz") + if err != nil { + sendSSE(w, flusher, "error", ProgressEvent{Phase: "init", Status: "error", Detail: "failed to create temp file"}) + return + } + tmpPath := tmpFile.Name() + + progressFn := func(ev ProgressEvent) { + sendSSE(w, flusher, "progress", ev) + } + + buildErr := h.writeExportArchive(r.Context(), tmpFile, ag, sections, progressFn) + tmpFile.Close() + + if buildErr != nil { + slog.Error("agents.export.sse", "agent_id", ag.ID, "error", buildErr) + sendSSE(w, flusher, "error", ProgressEvent{Phase: "archive", Status: "error", Detail: buildErr.Error()}) + os.Remove(tmpPath) + return + } + + userID := store.UserIDFromContext(r.Context()) + token := h.generateExportToken(ag.ID.String(), userID, tmpPath, exportFileName(ag.AgentKey)) + sendSSE(w, flusher, "complete", map[string]string{ + "download_url": "/v1/agents/" + ag.ID.String() + "/export/download/" + token, + }) +} + +// handleExportDirect streams the tar.gz archive directly to the response body. +func (h *AgentsHandler) handleExportDirect(w http.ResponseWriter, r *http.Request, ag *store.AgentData, sections map[string]bool) { + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, exportFileName(ag.AgentKey))) + + if err := h.writeExportArchive(r.Context(), w, ag, sections, nil); err != nil { + // Headers already written; log only — cannot send JSON error at this point. + slog.Error("agents.export.direct", "agent_id", ag.ID, "error", err) + } +} + +// writeExportArchive builds a tar.gz archive into w, calling progressFn after each section. +// progressFn may be nil (direct mode). +func (h *AgentsHandler) writeExportArchive(ctx context.Context, w io.Writer, ag *store.AgentData, sections map[string]bool, progressFn func(ProgressEvent)) error { + lw := &limitedWriter{w: w, limit: maxExportSize} + gw := gzip.NewWriter(lw) + tw := tar.NewWriter(gw) + + manifest := &ExportManifest{ + Version: 1, + Format: "goclaw-agent-export", + ExportedAt: time.Now().UTC().Format(time.RFC3339), + ExportedBy: store.UserIDFromContext(ctx), + AgentKey: ag.AgentKey, + AgentID: ag.ID.String(), + Sections: make(map[string]interface{}), + } + + // Section: config (always included) + agentJSON, err := marshalAgentConfig(ag) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal agent config: %w", err) + } + if err := addToTar(tw, "agent.json", agentJSON); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write agent.json: %w", err) + } + manifest.Sections["config"] = map[string]int{"count": 1} + if progressFn != nil { + progressFn(ProgressEvent{Phase: "config", Status: "done", Current: 1, Total: 1}) + } + + // Section: context_files (agent-level + per-user) + if sections["context_files"] { + files, err := pg.ExportAgentContextFiles(ctx, h.db, ag.ID) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("export context_files: %w", err) + } + for i, f := range files { + if progressFn != nil { + progressFn(ProgressEvent{Phase: "context_files", Status: "running", Current: i + 1, Total: len(files)}) + } + if err := addToTar(tw, "context_files/"+sanitizeName(f.FileName), []byte(f.Content)); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write context file %s: %w", f.FileName, err) + } + } + manifest.Sections["context_files"] = map[string]int{"count": len(files)} + if progressFn != nil { + progressFn(ProgressEvent{Phase: "context_files", Status: "done", Current: len(files), Total: len(files), Detail: fmt.Sprintf("%d files", len(files))}) + } + + userFiles, err := pg.ExportUserContextFiles(ctx, h.db, ag.ID) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("export user_context_files: %w", err) + } + for i, f := range userFiles { + if progressFn != nil { + progressFn(ProgressEvent{Phase: "user_context_files", Status: "running", Current: i + 1, Total: len(userFiles)}) + } + path := "user_context_files/" + sanitizeName(f.UserID) + "/" + sanitizeName(f.FileName) + if err := addToTar(tw, path, []byte(f.Content)); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write user context file %s: %w", f.FileName, err) + } + } + manifest.Sections["user_context_files"] = map[string]int{"count": len(userFiles)} + if progressFn != nil { + progressFn(ProgressEvent{Phase: "user_context_files", Status: "done", Current: len(userFiles), Total: len(userFiles), Detail: fmt.Sprintf("%d files", len(userFiles))}) + } + } + + // Section: memory + if sections["memory"] { + docs, err := pg.ExportMemoryDocuments(ctx, h.db, ag.ID) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("export memory: %w", err) + } + + globalDocs := make([]MemoryExport, 0) + perUser := make(map[string][]MemoryExport) + for _, d := range docs { + me := MemoryExport{Path: d.Path, Content: d.Content, UserID: d.UserID} + if d.UserID == "" { + globalDocs = append(globalDocs, me) + } else { + perUser[d.UserID] = append(perUser[d.UserID], me) + } + } + + if len(globalDocs) > 0 { + data, err := marshalJSONL(globalDocs) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal global memory: %w", err) + } + if err := addToTar(tw, "memory/global.jsonl", data); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write memory/global.jsonl: %w", err) + } + } + for uid, udocs := range perUser { + data, err := marshalJSONL(udocs) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal memory for user %s: %w", uid, err) + } + if err := addToTar(tw, "memory/users/"+sanitizeName(uid)+".jsonl", data); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write memory/users/%s.jsonl: %w", uid, err) + } + } + manifest.Sections["memory"] = map[string]int{ + "global": len(globalDocs), + "per_user": len(docs) - len(globalDocs), + } + if progressFn != nil { + progressFn(ProgressEvent{Phase: "memory", Status: "done", Current: len(docs), Total: len(docs), Detail: fmt.Sprintf("%d docs", len(docs))}) + } + } + + // Section: knowledge_graph + if sections["knowledge_graph"] { + entities, err := pg.ExportKGEntities(ctx, h.db, ag.ID) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("export kg entities: %w", err) + } + + // Build internal-id → external_id map for relation remapping + idToExternal := make(map[string]string, len(entities)) + exportEntities := make([]KGEntityExport, 0, len(entities)) + for _, e := range entities { + idToExternal[e.ID] = e.ExternalID + exportEntities = append(exportEntities, KGEntityExport{ + ExternalID: e.ExternalID, + UserID: e.UserID, + Name: e.Name, + EntityType: e.EntityType, + Description: e.Description, + Properties: e.Properties, + Confidence: e.Confidence, + }) + } + + if len(exportEntities) > 0 { + data, err := marshalJSONL(exportEntities) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal kg entities: %w", err) + } + if err := addToTar(tw, "knowledge_graph/entities.jsonl", data); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write kg entities: %w", err) + } + } + if progressFn != nil { + progressFn(ProgressEvent{Phase: "knowledge_graph_entities", Status: "done", Current: len(entities), Total: len(entities), Detail: fmt.Sprintf("%d entities", len(entities))}) + } + + relations, err := pg.ExportKGRelations(ctx, h.db, ag.ID) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("export kg relations: %w", err) + } + + exportRelations := make([]KGRelationExport, 0, len(relations)) + for _, rel := range relations { + exportRelations = append(exportRelations, KGRelationExport{ + SourceExternalID: idToExternal[rel.SourceEntityID], + TargetExternalID: idToExternal[rel.TargetEntityID], + UserID: rel.UserID, + RelationType: rel.RelationType, + Confidence: rel.Confidence, + Properties: rel.Properties, + }) + } + + if len(exportRelations) > 0 { + data, err := marshalJSONL(exportRelations) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal kg relations: %w", err) + } + if err := addToTar(tw, "knowledge_graph/relations.jsonl", data); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write kg relations: %w", err) + } + } + manifest.Sections["knowledge_graph"] = map[string]int{ + "entities": len(entities), + "relations": len(relations), + } + if progressFn != nil { + progressFn(ProgressEvent{Phase: "knowledge_graph_relations", Status: "done", Current: len(relations), Total: len(relations), Detail: fmt.Sprintf("%d relations", len(relations))}) + } + } + + // Section: cron + if sections["cron"] { + jobs, qErr := pg.ExportCronJobs(ctx, h.db, ag.ID) + if qErr != nil { + slog.Warn("export: failed to query cron jobs", "agent", ag.AgentKey, "error", qErr) + } + if len(jobs) > 0 { + data, err := marshalJSONL(jobs) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal cron jobs: %w", err) + } + if err := addToTar(tw, "cron/jobs.jsonl", data); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write cron/jobs.jsonl: %w", err) + } + } + manifest.Sections["cron"] = map[string]int{"count": len(jobs)} + if progressFn != nil { + progressFn(ProgressEvent{Phase: "cron", Status: "done", Detail: fmt.Sprintf("%d jobs", len(jobs))}) + } + } + + // Section: user_profiles + if sections["user_profiles"] { + profiles, qErr := pg.ExportUserProfiles(ctx, h.db, ag.ID) + if qErr != nil { + slog.Warn("export: failed to query user profiles", "agent", ag.AgentKey, "error", qErr) + } + if len(profiles) > 0 { + data, err := marshalJSONL(profiles) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal user profiles: %w", err) + } + if err := addToTar(tw, "user_profiles.jsonl", data); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write user_profiles.jsonl: %w", err) + } + } + manifest.Sections["user_profiles"] = map[string]int{"count": len(profiles)} + if progressFn != nil { + progressFn(ProgressEvent{Phase: "user_profiles", Status: "done", Detail: fmt.Sprintf("%d profiles", len(profiles))}) + } + } + + // Section: user_overrides + if sections["user_overrides"] { + overrides, qErr := pg.ExportUserOverrides(ctx, h.db, ag.ID) + if qErr != nil { + slog.Warn("export: failed to query user overrides", "agent", ag.AgentKey, "error", qErr) + } + if len(overrides) > 0 { + data, err := marshalJSONL(overrides) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal user overrides: %w", err) + } + if err := addToTar(tw, "user_overrides.jsonl", data); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write user_overrides.jsonl: %w", err) + } + } + manifest.Sections["user_overrides"] = map[string]int{"count": len(overrides)} + if progressFn != nil { + progressFn(ProgressEvent{Phase: "user_overrides", Status: "done", Detail: fmt.Sprintf("%d overrides", len(overrides))}) + } + } + + // Section: workspace files + if sections["workspace"] && ag.Workspace != "" { + wsPath := config.ExpandHome(ag.Workspace) + fileCount, totalBytes, wsErr := h.exportWorkspaceFiles(ctx, tw, wsPath, progressFn) + if wsErr != nil { + slog.Warn("export: workspace walk failed", "path", wsPath, "error", wsErr) + } + manifest.Sections["workspace"] = map[string]any{"file_count": fileCount, "total_bytes": totalBytes} + } + + // Manifest last — has accurate final counts + manifestJSON, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal manifest: %w", err) + } + if err := addToTar(tw, "manifest.json", manifestJSON); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write manifest: %w", err) + } + + if err := tw.Close(); err != nil { + gw.Close() + return fmt.Errorf("close tar: %w", err) + } + return gw.Close() +} + +// canExport checks if userID has permission to export the given agent. +func (h *AgentsHandler) canExport(ag *store.AgentData, userID string) bool { + if ag.OwnerID == userID { + return true + } + if h.isOwnerUser(userID) { + return true + } + // TODO: tenant admin check + return false +} + +// generateExportToken is an alias for storeExportToken (backward compat within AgentsHandler). +func (h *AgentsHandler) generateExportToken(entityID, userID, filePath, fileName string) string { + return storeExportToken(entityID, userID, filePath, fileName) +} + +// addToTar adds a single file to the tar archive with a standard header. +func addToTar(tw *tar.Writer, name string, data []byte) error { + hdr := &tar.Header{ + Name: name, + Mode: 0o644, + Size: int64(len(data)), + ModTime: time.Now(), + } + if err := tw.WriteHeader(hdr); err != nil { + return err + } + _, err := tw.Write(data) + return err +} + +// marshalJSONL encodes items as newline-delimited JSON (one object per line). +func marshalJSONL[T any](items []T) ([]byte, error) { + var sb strings.Builder + enc := json.NewEncoder(&sb) + for _, item := range items { + if err := enc.Encode(item); err != nil { + return nil, err + } + } + return []byte(sb.String()), nil +} + +// marshalAgentConfig serializes an agent with sensitive fields (tenant_id, owner_id) stripped. +func marshalAgentConfig(ag *store.AgentData) ([]byte, error) { + type exportableAgent struct { + AgentKey string `json:"agent_key"` + DisplayName string `json:"display_name,omitempty"` + Frontmatter string `json:"frontmatter,omitempty"` + Provider string `json:"provider"` + Model string `json:"model"` + ContextWindow int `json:"context_window"` + MaxToolIterations int `json:"max_tool_iterations"` + AgentType string `json:"agent_type"` + Status string `json:"status"` + ToolsConfig json.RawMessage `json:"tools_config,omitempty"` + SandboxConfig json.RawMessage `json:"sandbox_config,omitempty"` + SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"` + MemoryConfig json.RawMessage `json:"memory_config,omitempty"` + CompactionConfig json.RawMessage `json:"compaction_config,omitempty"` + ContextPruning json.RawMessage `json:"context_pruning,omitempty"` + OtherConfig json.RawMessage `json:"other_config,omitempty"` + } + return json.MarshalIndent(exportableAgent{ + AgentKey: ag.AgentKey, + DisplayName: ag.DisplayName, + Frontmatter: ag.Frontmatter, + Provider: ag.Provider, + Model: ag.Model, + ContextWindow: ag.ContextWindow, + MaxToolIterations: ag.MaxToolIterations, + AgentType: ag.AgentType, + Status: ag.Status, + ToolsConfig: ag.ToolsConfig, + SandboxConfig: ag.SandboxConfig, + SubagentsConfig: ag.SubagentsConfig, + MemoryConfig: ag.MemoryConfig, + CompactionConfig: ag.CompactionConfig, + ContextPruning: ag.ContextPruning, + OtherConfig: ag.OtherConfig, + }, "", " ") +} + +// parseExportSections parses the ?sections= query param. +// Defaults to config + context_files when empty. +func parseExportSections(raw string) map[string]bool { + if raw == "" { + return map[string]bool{"config": true, "context_files": true} + } + out := make(map[string]bool) + for _, s := range strings.Split(raw, ",") { + if s = strings.TrimSpace(s); s != "" { + out[s] = true + } + } + return out +} + +// exportFileName builds the tar.gz filename for a given agent key. +// Strips characters unsafe for Content-Disposition headers. +func exportFileName(agentKey string) string { + safe := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + return r + } + return '-' + }, agentKey) + return fmt.Sprintf("agent-%s-%s.tar.gz", safe, time.Now().UTC().Format("20060102")) +} + +// jsonIndent marshals v to indented JSON bytes. +func jsonIndent(v any) ([]byte, error) { + return json.MarshalIndent(v, "", " ") +} + +// sanitizeName replaces characters that could cause path traversal in tar entries. +// Use for single-segment names (file names, agent keys). NOT for relative paths with slashes. +func sanitizeName(name string) string { + r := strings.NewReplacer("/", "_", "..", "__", "\\", "_") + return r.Replace(name) +} + +// sanitizeRelPath sanitizes each segment of a relative path while preserving directory structure. +// Removes ".." traversal and backslashes but keeps forward slashes between segments. +func sanitizeRelPath(relPath string) string { + parts := strings.Split(relPath, "/") + clean := make([]string, 0, len(parts)) + for _, p := range parts { + if p == "" || p == "." || p == ".." { + continue + } + clean = append(clean, strings.ReplaceAll(p, "\\", "_")) + } + return strings.Join(clean, "/") +} + +// limitedWriter wraps an io.Writer and returns an error once limit bytes are exceeded. +type limitedWriter struct { + w io.Writer + written int64 + limit int64 +} + +func (lw *limitedWriter) Write(p []byte) (int, error) { + if lw.written+int64(len(p)) > lw.limit { + return 0, errors.New("export size limit exceeded") + } + n, err := lw.w.Write(p) + lw.written += int64(n) + return n, err +} diff --git a/internal/http/agents_export_team.go b/internal/http/agents_export_team.go new file mode 100644 index 00000000..f45c39d4 --- /dev/null +++ b/internal/http/agents_export_team.go @@ -0,0 +1,142 @@ +package http + +import ( + "archive/tar" + "context" + "fmt" + "log/slog" + "path/filepath" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" +) + +// exportTeamSection exports team metadata, members, tasks, comments, events, links, +// and team workspace files into the tar archive under the "team/" prefix. +func (h *AgentsHandler) exportTeamSection(ctx context.Context, tw *tar.Writer, agentID uuid.UUID, manifest *ExportManifest, progressFn func(ProgressEvent)) error { + team, teamID, members, err := pg.ExportTeamByLead(ctx, h.db, agentID) + if err != nil { + return fmt.Errorf("query team: %w", err) + } + if team == nil { + // Agent is not a team lead — nothing to export + manifest.Sections["team"] = map[string]any{"lead": false} + return nil + } + + // team/team.json + teamJSON, err := jsonIndent(team) + if err != nil { + return fmt.Errorf("marshal team: %w", err) + } + if err := addToTar(tw, "team/team.json", teamJSON); err != nil { + return fmt.Errorf("write team/team.json: %w", err) + } + + // team/members.jsonl + if len(members) > 0 { + data, err := marshalJSONL(members) + if err != nil { + return fmt.Errorf("marshal members: %w", err) + } + if err := addToTar(tw, "team/members.jsonl", data); err != nil { + return fmt.Errorf("write team/members.jsonl: %w", err) + } + } + + // team/tasks.jsonl + tasksExport, err := pg.ExportTeamTasks(ctx, h.db, teamID) + if err != nil { + slog.Warn("export: team tasks query failed", "team_id", teamID, "error", err) + tasksExport = &pg.TeamTasksExport{} + } + if len(tasksExport.Tasks) > 0 { + data, err := marshalJSONL(tasksExport.Tasks) + if err != nil { + return fmt.Errorf("marshal tasks: %w", err) + } + if err := addToTar(tw, "team/tasks.jsonl", data); err != nil { + return fmt.Errorf("write team/tasks.jsonl: %w", err) + } + } + + // team/comments.jsonl + comments, err := pg.ExportTeamComments(ctx, h.db, teamID, tasksExport.TaskUIDs) + if err != nil { + slog.Warn("export: team comments query failed", "team_id", teamID, "error", err) + } + if len(comments) > 0 { + data, err := marshalJSONL(comments) + if err != nil { + return fmt.Errorf("marshal comments: %w", err) + } + if err := addToTar(tw, "team/comments.jsonl", data); err != nil { + return fmt.Errorf("write team/comments.jsonl: %w", err) + } + } + + // team/events.jsonl + events, err := pg.ExportTeamEvents(ctx, h.db, teamID, tasksExport.TaskUIDs) + if err != nil { + slog.Warn("export: team events query failed", "team_id", teamID, "error", err) + } + if len(events) > 0 { + data, err := marshalJSONL(events) + if err != nil { + return fmt.Errorf("marshal events: %w", err) + } + if err := addToTar(tw, "team/events.jsonl", data); err != nil { + return fmt.Errorf("write team/events.jsonl: %w", err) + } + } + + // team/links.jsonl — agent_links for this agent + links, err := pg.ExportAgentLinks(ctx, h.db, agentID) + if err != nil { + slog.Warn("export: agent links query failed", "agent_id", agentID, "error", err) + } + if len(links) > 0 { + data, err := marshalJSONL(links) + if err != nil { + return fmt.Errorf("marshal links: %w", err) + } + if err := addToTar(tw, "team/links.jsonl", data); err != nil { + return fmt.Errorf("write team/links.jsonl: %w", err) + } + } + + // team/workspace/ — team filesystem workspace + var wsFileCount int + var wsTotalBytes int64 + if h.dataDir != "" { + wsPath := filepath.Join(config.ExpandHome(h.dataDir), "teams", teamID.String()) + wsFileCount, wsTotalBytes, _ = h.exportTeamWorkspaceFiles(ctx, tw, wsPath, progressFn) + } + + manifest.Sections["team"] = map[string]any{ + "lead": true, + "members": len(members), + "tasks": len(tasksExport.Tasks), + "comments": len(comments), + "events": len(events), + "links": len(links), + "ws_files": wsFileCount, + "ws_bytes": wsTotalBytes, + } + if progressFn != nil { + progressFn(ProgressEvent{ + Phase: "team", + Status: "done", + Current: len(tasksExport.Tasks), + Total: len(tasksExport.Tasks), + }) + } + return nil +} + +// exportTeamWorkspaceFiles walks the team workspace directory and adds files under "team/workspace/". +func (h *AgentsHandler) exportTeamWorkspaceFiles(ctx context.Context, tw *tar.Writer, wsPath string, progressFn func(ProgressEvent)) (int, int64, error) { + return h.exportWorkspaceFilesWithPrefix(ctx, tw, wsPath, "team/workspace/", progressFn) +} diff --git a/internal/http/agents_import.go b/internal/http/agents_import.go new file mode 100644 index 00000000..b7d858c0 --- /dev/null +++ b/internal/http/agents_import.go @@ -0,0 +1,622 @@ +package http + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "sync" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +const maxImportBodySize = 500 << 20 // 500MB + +// importCleanup tracks created context files for rollback on failure. +type importCleanup struct { + mu sync.Mutex + files []string // agent context file names (for log only; DB rollback handles actual cleanup) +} + +func (c *importCleanup) TrackFile(name string) { + c.mu.Lock() + c.files = append(c.files, name) + c.mu.Unlock() +} + +// importArchive is the parsed contents of an agent archive. +type importArchive struct { + manifest *ExportManifest + agentConfig map[string]json.RawMessage + contextFiles []importContextFile + userContextFiles []importUserContextFile + memoryGlobal []MemoryExport + memoryUsers map[string][]MemoryExport // userID → docs + kgEntities []KGEntityExport + kgRelations []KGRelationExport + cronJobs []pg.CronJobExport + userProfiles []pg.UserProfileExport + userOverrides []pg.UserOverrideExport + workspaceFiles map[string][]byte // relative path → content + // Team section (used by standalone team import) + teamMeta *pg.TeamExport + teamMembers []pg.TeamMemberExport + teamTasks []pg.TeamTaskExport + teamComments []pg.TeamTaskCommentExport + teamEvents []pg.TeamTaskEventExport + teamLinks []pg.AgentLinkExport + teamWorkspace map[string][]byte // relative path → content +} + +type importContextFile struct { + fileName string + content string +} + +type importUserContextFile struct { + userID string + fileName string + content string +} + +// ImportSummary is returned after a successful import. +type ImportSummary struct { + AgentID string `json:"agent_id"` + AgentKey string `json:"agent_key"` + ContextFiles int `json:"context_files"` + MemoryDocs int `json:"memory_docs"` + KGEntities int `json:"kg_entities"` + KGRelations int `json:"kg_relations"` +} + +// parseImportSections parses the ?include= query param (comma-separated section names). +// Defaults to all sections if empty. +func parseImportSections(raw string) map[string]bool { + all := map[string]bool{ + "config": true, + "context_files": true, + "memory": true, + "knowledge_graph": true, + "cron": true, + "user_profiles": true, + "user_overrides": true, + "workspace": true, + "team": true, + } + if raw == "" { + return all + } + out := make(map[string]bool) + for _, s := range strings.Split(raw, ",") { + if s = strings.TrimSpace(s); s != "" { + out[s] = true + } + } + return out +} + +// canImport checks if userID has permission to import agents (system owner only for now). +func (h *AgentsHandler) canImport(userID string) bool { + return h.isOwnerUser(userID) +} + +// handleImportPreview parses the archive manifest and returns it without importing. +func (h *AgentsHandler) handleImportPreview(w http.ResponseWriter, r *http.Request) { + userID := store.UserIDFromContext(r.Context()) + locale := store.LocaleFromContext(r.Context()) + + if !h.canImport(userID) { + writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "import")) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxImportBodySize) + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "multipart parse: "+err.Error())) + return + } + + f, _, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "missing 'file' field")) + return + } + defer f.Close() + + arc, err := readImportArchive(f) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "archive parse: "+err.Error())) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "manifest": arc.manifest, + "context_files": len(arc.contextFiles), + "memory_docs": len(arc.memoryGlobal) + countUserMemory(arc.memoryUsers), + "kg_entities": len(arc.kgEntities), + "kg_relations": len(arc.kgRelations), + }) +} + +// handleImport creates a new agent from an uploaded archive. +func (h *AgentsHandler) handleImport(w http.ResponseWriter, r *http.Request) { + userID := store.UserIDFromContext(r.Context()) + locale := store.LocaleFromContext(r.Context()) + + if !h.canImport(userID) { + writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "import agent")) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxImportBodySize) + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "multipart parse: "+err.Error())) + return + } + + f, _, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "missing 'file' field")) + return + } + defer f.Close() + + arc, err := readImportArchive(f) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "archive parse: "+err.Error())) + return + } + + stream := r.URL.Query().Get("stream") == "true" + if stream { + flusher := initSSE(w) + if flusher == nil { + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, "streaming not supported") + return + } + progressFn := func(ev ProgressEvent) { sendSSE(w, flusher, "progress", ev) } + summary, importErr := h.doImportNewAgent(r.Context(), r, arc, progressFn) + if importErr != nil { + sendSSE(w, flusher, "error", ProgressEvent{Phase: "import", Status: "error", Detail: importErr.Error()}) + return + } + sendSSE(w, flusher, "complete", summary) + return + } + + summary, err := h.doImportNewAgent(r.Context(), r, arc, nil) + if err != nil { + slog.Error("agents.import", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, err.Error())) + return + } + writeJSON(w, http.StatusCreated, summary) +} + +// handleMergeImport merges archive data into an existing agent. +func (h *AgentsHandler) handleMergeImport(w http.ResponseWriter, r *http.Request) { + userID := store.UserIDFromContext(r.Context()) + locale := store.LocaleFromContext(r.Context()) + + ag, status, err := h.lookupAccessibleAgent(r) + if err != nil { + writeError(w, status, protocol.ErrNotFound, err.Error()) + return + } + // Require agent owner or system owner + if ag.OwnerID != userID && !h.isOwnerUser(userID) { + writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "merge import")) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxImportBodySize) + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "multipart parse: "+err.Error())) + return + } + + f, _, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "missing 'file' field")) + return + } + defer f.Close() + + arc, err := readImportArchive(f) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "archive parse: "+err.Error())) + return + } + + sections := parseImportSections(r.URL.Query().Get("include")) + stream := r.URL.Query().Get("stream") == "true" + + if stream { + flusher := initSSE(w) + if flusher == nil { + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, "streaming not supported") + return + } + progressFn := func(ev ProgressEvent) { sendSSE(w, flusher, "progress", ev) } + summary, mergeErr := h.doMergeImport(r.Context(), ag, arc, sections, progressFn) + if mergeErr != nil { + slog.Error("agents.merge_import.sse", "agent_id", ag.ID, "error", mergeErr) + sendSSE(w, flusher, "error", map[string]any{"phase": "merge", "detail": mergeErr.Error(), "rolled_back": false}) + return + } + sendSSE(w, flusher, "complete", summary) + return + } + + summary, err := h.doMergeImport(r.Context(), ag, arc, sections, nil) + if err != nil { + slog.Error("agents.merge_import", "agent_id", ag.ID, "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, err.Error())) + return + } + writeJSON(w, http.StatusOK, summary) +} + +// doImportNewAgent creates a new agent from the archive, returning import summary. +func (h *AgentsHandler) doImportNewAgent(ctx context.Context, r *http.Request, arc *importArchive, progressFn func(ProgressEvent)) (*ImportSummary, error) { + tenantID := store.TenantIDFromContext(ctx) + userID := store.UserIDFromContext(ctx) + + // Build agent record from archive config + optional overrides + agentKey := r.FormValue("agent_key") + displayName := r.FormValue("display_name") + + if agentKey == "" && arc.agentConfig["agent_key"] != nil { + json.Unmarshal(arc.agentConfig["agent_key"], &agentKey) //nolint:errcheck + } + if displayName == "" && arc.agentConfig["display_name"] != nil { + json.Unmarshal(arc.agentConfig["display_name"], &displayName) //nolint:errcheck + } + if agentKey == "" && displayName != "" { + agentKey = config.NormalizeAgentID(displayName) + } + if agentKey == "" { + return nil, errors.New("agent_key is required (not found in archive or request)") + } + + // Dedup: suffix with -N if key already exists + agentKey = h.dedupAgentKey(ctx, agentKey) + + ag := h.buildAgentFromArchive(arc.agentConfig, agentKey, displayName, tenantID, userID) + + if progressFn != nil { + progressFn(ProgressEvent{Phase: "config", Status: "running"}) + } + + if err := h.agents.Create(ctx, ag); err != nil { + return nil, fmt.Errorf("create agent: %w", err) + } + + if progressFn != nil { + progressFn(ProgressEvent{Phase: "config", Status: "done", Current: 1, Total: 1}) + } + + sections := map[string]bool{ + "context_files": true, + "memory": true, + "knowledge_graph": true, + "cron": true, + "user_profiles": true, + "user_overrides": true, + "workspace": true, + "team": true, + } + summary, err := h.doMergeImport(ctx, ag, arc, sections, progressFn) + if err != nil { + // Best-effort: agent already created, log but return partial summary + slog.Error("agents.import.merge_data", "agent_id", ag.ID, "error", err) + return &ImportSummary{AgentID: ag.ID.String(), AgentKey: ag.AgentKey}, err + } + summary.AgentID = ag.ID.String() + summary.AgentKey = ag.AgentKey + return summary, nil +} + +// doMergeImport upserts sections of the archive into the target agent. +func (h *AgentsHandler) doMergeImport(ctx context.Context, ag *store.AgentData, arc *importArchive, sections map[string]bool, progressFn func(ProgressEvent)) (*ImportSummary, error) { + summary := &ImportSummary{AgentID: ag.ID.String(), AgentKey: ag.AgentKey} + + // Section: context_files + if sections["context_files"] { + if progressFn != nil { + progressFn(ProgressEvent{Phase: "context_files", Status: "running", Total: len(arc.contextFiles)}) + } + for _, cf := range arc.contextFiles { + if err := h.agents.SetAgentContextFile(ctx, ag.ID, cf.fileName, cf.content); err != nil { + return nil, fmt.Errorf("set context file %s: %w", cf.fileName, err) + } + summary.ContextFiles++ + } + for _, ucf := range arc.userContextFiles { + if err := h.agents.SetUserContextFile(ctx, ag.ID, ucf.userID, ucf.fileName, ucf.content); err != nil { + return nil, fmt.Errorf("set user context file %s/%s: %w", ucf.userID, ucf.fileName, err) + } + summary.ContextFiles++ + } + if progressFn != nil { + progressFn(ProgressEvent{Phase: "context_files", Status: "done", Current: summary.ContextFiles, Total: summary.ContextFiles}) + } + } + + // Section: memory + if sections["memory"] && h.memoryStore != nil { + totalDocs := len(arc.memoryGlobal) + countUserMemory(arc.memoryUsers) + if progressFn != nil { + progressFn(ProgressEvent{Phase: "memory", Status: "running", Total: totalDocs}) + } + for _, doc := range arc.memoryGlobal { + if err := h.memoryStore.PutDocument(ctx, ag.ID.String(), "", doc.Path, doc.Content); err != nil { + return nil, fmt.Errorf("put memory doc %s: %w", doc.Path, err) + } + summary.MemoryDocs++ + } + for uid, docs := range arc.memoryUsers { + for _, doc := range docs { + if err := h.memoryStore.PutDocument(ctx, ag.ID.String(), uid, doc.Path, doc.Content); err != nil { + return nil, fmt.Errorf("put memory doc %s/%s: %w", uid, doc.Path, err) + } + summary.MemoryDocs++ + } + } + if progressFn != nil { + progressFn(ProgressEvent{Phase: "memory", Status: "done", Current: summary.MemoryDocs, Total: totalDocs}) + } + // Async re-index + // Extract paths before goroutine to allow arc GC + paths := collectMemoryPaths(arc) + go h.reindexMemoryPaths(context.WithoutCancel(ctx), ag.ID.String(), paths) + } + + // Section: knowledge_graph + if sections["knowledge_graph"] && h.kgStore != nil && len(arc.kgEntities) > 0 { + if progressFn != nil { + progressFn(ProgressEvent{Phase: "knowledge_graph", Status: "running", Total: len(arc.kgEntities)}) + } + if err := h.ingestKGByUser(ctx, ag.ID.String(), arc); err != nil { + return nil, fmt.Errorf("ingest kg: %w", err) + } + summary.KGEntities = len(arc.kgEntities) + summary.KGRelations = len(arc.kgRelations) + if progressFn != nil { + progressFn(ProgressEvent{Phase: "knowledge_graph", Status: "done", Current: len(arc.kgEntities), Total: len(arc.kgEntities)}) + } + } + + // Section: cron — always imported as disabled, skip duplicates by name + if sections["cron"] && len(arc.cronJobs) > 0 { + tid := importTenantID(ctx) + for _, j := range arc.cronJobs { + // Check if cron job with same name already exists (no UNIQUE constraint on name) + var exists bool + _ = h.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM cron_jobs WHERE agent_id = $1 AND name = $2 AND tenant_id = $3)`, + ag.ID, j.Name, tid, + ).Scan(&exists) + if exists { + continue + } + _, err := h.db.ExecContext(ctx, + `INSERT INTO cron_jobs + (agent_id, name, enabled, schedule_kind, cron_expression, interval_ms, run_at, timezone, payload, delete_after_run, tenant_id) + VALUES ($1, $2, false, $3, $4, $5, $6, $7, $8, $9, $10)`, + ag.ID, j.Name, j.ScheduleKind, + j.CronExpression, j.IntervalMS, nullStr(j.RunAt), j.Timezone, + j.Payload, j.DeleteAfterRun, tid, + ) + if err != nil { + slog.Warn("agents.import.cron_job", "agent_id", ag.ID, "name", j.Name, "error", err) + } + } + if progressFn != nil { + progressFn(ProgressEvent{Phase: "cron", Status: "done", Current: len(arc.cronJobs), Total: len(arc.cronJobs)}) + } + } + + // Section: user_profiles — insert if not exists, workspace=NULL for portability + // (workspace is auto-created via GetOrCreateUserProfile on first user access) + if sections["user_profiles"] && len(arc.userProfiles) > 0 { + tid := importTenantID(ctx) + for _, p := range arc.userProfiles { + _, err := h.db.ExecContext(ctx, + `INSERT INTO user_agent_profiles (agent_id, user_id, workspace, tenant_id) + VALUES ($1, $2, NULL, $3) + ON CONFLICT DO NOTHING`, + ag.ID, p.UserID, tid, + ) + if err != nil { + slog.Warn("agents.import.user_profile", "agent_id", ag.ID, "user_id", p.UserID, "error", err) + } + } + if progressFn != nil { + progressFn(ProgressEvent{Phase: "user_profiles", Status: "done", Current: len(arc.userProfiles), Total: len(arc.userProfiles)}) + } + } + + // Section: user_overrides — insert if not exists + if sections["user_overrides"] && len(arc.userOverrides) > 0 { + tid := importTenantID(ctx) + for _, o := range arc.userOverrides { + _, err := h.db.ExecContext(ctx, + `INSERT INTO user_agent_overrides (agent_id, user_id, provider, model, settings, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT DO NOTHING`, + ag.ID, o.UserID, o.Provider, o.Model, coalesceJSON(o.Settings), tid, + ) + if err != nil { + slog.Warn("agents.import.user_override", "agent_id", ag.ID, "user_id", o.UserID, "error", err) + } + } + if progressFn != nil { + progressFn(ProgressEvent{Phase: "user_overrides", Status: "done", Current: len(arc.userOverrides), Total: len(arc.userOverrides)}) + } + } + + // Section: workspace files + if sections["workspace"] && len(arc.workspaceFiles) > 0 { + wsPath := config.ExpandHome(fmt.Sprintf("%s/%s", h.defaultWorkspace, ag.AgentKey)) + imported, wsErr := extractWorkspaceFiles(wsPath, arc.workspaceFiles, false) + if wsErr != nil { + slog.Warn("import: workspace extraction failed", "path", wsPath, "error", wsErr) + } + if progressFn != nil { + progressFn(ProgressEvent{Phase: "workspace", Status: "done", Current: imported, Total: len(arc.workspaceFiles)}) + } + } + + // Section: team + if sections["team"] && arc.teamMeta != nil { + if err := h.importTeamSection(ctx, ag, arc, progressFn); err != nil { + slog.Warn("import: team section failed", "agent_id", ag.ID, "error", err) + } + } + + return summary, nil +} + +// ingestKGByUser groups KG entities/relations by user_id and calls IngestExtraction per group. +func (h *AgentsHandler) ingestKGByUser(ctx context.Context, agentID string, arc *importArchive) error { + // Group entities by user_id + type userGroup struct { + entities []store.Entity + relations []store.Relation + } + groups := make(map[string]*userGroup) + for _, e := range arc.kgEntities { + uid := e.UserID + if groups[uid] == nil { + groups[uid] = &userGroup{} + } + groups[uid].entities = append(groups[uid].entities, store.Entity{ + AgentID: agentID, + UserID: uid, + ExternalID: e.ExternalID, + Name: e.Name, + EntityType: e.EntityType, + Description: e.Description, + Properties: e.Properties, + Confidence: e.Confidence, + }) + } + for _, rel := range arc.kgRelations { + uid := rel.UserID + if groups[uid] == nil { + groups[uid] = &userGroup{} + } + groups[uid].relations = append(groups[uid].relations, store.Relation{ + AgentID: agentID, + UserID: uid, + SourceEntityID: rel.SourceExternalID, + TargetEntityID: rel.TargetExternalID, + RelationType: rel.RelationType, + Confidence: rel.Confidence, + Properties: rel.Properties, + }) + } + for uid, g := range groups { + if _, err := h.kgStore.IngestExtraction(ctx, agentID, uid, g.entities, g.relations); err != nil { + return fmt.Errorf("user %s: %w", uid, err) + } + } + return nil +} + +// memoryPathEntry is a lightweight reference for background re-indexing (avoids holding full arc). +type memoryPathEntry struct { + userID string + path string +} + +// collectMemoryPaths extracts just the paths from arc so the full archive can be GC'd. +func collectMemoryPaths(arc *importArchive) []memoryPathEntry { + var paths []memoryPathEntry + for _, doc := range arc.memoryGlobal { + paths = append(paths, memoryPathEntry{path: doc.Path}) + } + for uid, docs := range arc.memoryUsers { + for _, doc := range docs { + paths = append(paths, memoryPathEntry{userID: uid, path: doc.Path}) + } + } + return paths +} + +// reindexMemoryPaths re-indexes imported memory documents in background. +func (h *AgentsHandler) reindexMemoryPaths(ctx context.Context, agentID string, paths []memoryPathEntry) { + for _, p := range paths { + if err := h.memoryStore.IndexDocument(ctx, agentID, p.userID, p.path); err != nil { + slog.Warn("agents.import.reindex", "agent_id", agentID, "user_id", p.userID, "path", p.path, "error", err) + } + } +} + +// buildAgentFromArchive constructs an AgentData from the parsed archive config map. +func (h *AgentsHandler) buildAgentFromArchive(cfg map[string]json.RawMessage, agentKey, displayName string, tenantID uuid.UUID, ownerID string) *store.AgentData { + ag := &store.AgentData{ + AgentKey: agentKey, + DisplayName: displayName, + TenantID: tenantID, + OwnerID: ownerID, + Status: store.AgentStatusActive, + } + unmarshalField(cfg, "frontmatter", &ag.Frontmatter) + unmarshalField(cfg, "provider", &ag.Provider) + unmarshalField(cfg, "model", &ag.Model) + unmarshalField(cfg, "agent_type", &ag.AgentType) + unmarshalField(cfg, "context_window", &ag.ContextWindow) + unmarshalField(cfg, "max_tool_iterations", &ag.MaxToolIterations) + + ag.ToolsConfig = rawOrNil(cfg["tools_config"]) + ag.SandboxConfig = rawOrNil(cfg["sandbox_config"]) + ag.SubagentsConfig = rawOrNil(cfg["subagents_config"]) + ag.MemoryConfig = rawOrNil(cfg["memory_config"]) + ag.CompactionConfig = rawOrNil(cfg["compaction_config"]) + ag.ContextPruning = rawOrNil(cfg["context_pruning"]) + ag.OtherConfig = rawOrNil(cfg["other_config"]) + + if ag.AgentType == "" { + ag.AgentType = store.AgentTypeOpen + } + if ag.ContextWindow <= 0 { + ag.ContextWindow = config.DefaultContextWindow + } + if ag.MaxToolIterations <= 0 { + ag.MaxToolIterations = config.DefaultMaxIterations + } + ag.Workspace = fmt.Sprintf("%s/%s", h.defaultWorkspace, ag.AgentKey) + ag.RestrictToWorkspace = true + + if len(ag.CompactionConfig) == 0 { + ag.CompactionConfig = json.RawMessage(`{}`) + } + if len(ag.MemoryConfig) == 0 { + ag.MemoryConfig = json.RawMessage(`{"enabled":true}`) + } + return ag +} + +// dedupAgentKey appends -2, -3, ... until the key is unique in the store. +func (h *AgentsHandler) dedupAgentKey(ctx context.Context, base string) string { + key := base + for i := 2; i <= 100; i++ { + if existing, _ := h.agents.GetByKey(ctx, key); existing == nil { + return key + } + key = fmt.Sprintf("%s-%d", base, i) + } + return key +} + diff --git a/internal/http/agents_import_helpers.go b/internal/http/agents_import_helpers.go new file mode 100644 index 00000000..845c90fc --- /dev/null +++ b/internal/http/agents_import_helpers.go @@ -0,0 +1,295 @@ +package http + +import ( + "archive/tar" + "bufio" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" +) + +// readTarGzEntries decompresses a tar.gz and returns all entries as a map. +// Enforces per-entry limit (maxImportBodySize) and cumulative decompressed size limit. +func readTarGzEntries(r io.Reader) (map[string][]byte, error) { + gr, err := gzip.NewReader(r) + if err != nil { + return nil, fmt.Errorf("gzip open: %w", err) + } + defer gr.Close() + + tr := tar.NewReader(gr) + entries := make(map[string][]byte) + var totalBytes int64 + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("tar read: %w", err) + } + data, err := io.ReadAll(io.LimitReader(tr, maxImportBodySize)) + if err != nil { + return nil, fmt.Errorf("read entry %s: %w", hdr.Name, err) + } + totalBytes += int64(len(data)) + if totalBytes > maxImportBodySize { + return nil, fmt.Errorf("archive exceeds maximum decompressed size (%d bytes)", maxImportBodySize) + } + entries[hdr.Name] = data + } + return entries, nil +} + +// readImportArchive extracts the archive into an importArchive struct. +func readImportArchive(r io.Reader) (*importArchive, error) { + entries, err := readTarGzEntries(r) + if err != nil { + return nil, err + } + + arc := &importArchive{ + memoryUsers: make(map[string][]MemoryExport), + workspaceFiles: make(map[string][]byte), + } + + if raw, ok := entries["manifest.json"]; ok { + var m ExportManifest + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("parse manifest: %w", err) + } + arc.manifest = &m + } + + if raw, ok := entries["agent.json"]; ok { + var cfg map[string]json.RawMessage + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("parse agent.json: %w", err) + } + arc.agentConfig = cfg + } + + for name, data := range entries { + switch { + case strings.HasPrefix(name, "context_files/"): + fileName := strings.TrimPrefix(name, "context_files/") + if fileName != "" { + arc.contextFiles = append(arc.contextFiles, importContextFile{ + fileName: fileName, + content: string(data), + }) + } + case strings.HasPrefix(name, "user_context_files/"): + rest := strings.TrimPrefix(name, "user_context_files/") + parts := strings.SplitN(rest, "/", 2) + if len(parts) == 2 && parts[0] != "" && parts[1] != "" { + arc.userContextFiles = append(arc.userContextFiles, importUserContextFile{ + userID: parts[0], + fileName: parts[1], + content: string(data), + }) + } + case name == "memory/global.jsonl": + docs, err := parseJSONL[MemoryExport](data) + if err != nil { + return nil, fmt.Errorf("parse memory/global.jsonl: %w", err) + } + arc.memoryGlobal = docs + case strings.HasPrefix(name, "memory/users/") && strings.HasSuffix(name, ".jsonl"): + uid := strings.TrimSuffix(strings.TrimPrefix(name, "memory/users/"), ".jsonl") + docs, err := parseJSONL[MemoryExport](data) + if err != nil { + return nil, fmt.Errorf("parse memory/users/%s.jsonl: %w", uid, err) + } + arc.memoryUsers[uid] = docs + case name == "knowledge_graph/entities.jsonl": + entities, err := parseJSONL[KGEntityExport](data) + if err != nil { + return nil, fmt.Errorf("parse kg entities: %w", err) + } + arc.kgEntities = entities + case name == "knowledge_graph/relations.jsonl": + relations, err := parseJSONL[KGRelationExport](data) + if err != nil { + return nil, fmt.Errorf("parse kg relations: %w", err) + } + arc.kgRelations = relations + case strings.HasPrefix(name, "workspace/"): + rel := strings.TrimPrefix(name, "workspace/") + if rel != "" { + arc.workspaceFiles[rel] = data + } + } + } + + if err := readNewSections(arc, entries); err != nil { + return nil, err + } + + return arc, nil +} + +// parseJSONL decodes newline-delimited JSON into a slice of T. +func parseJSONL[T any](data []byte) ([]T, error) { + var result []T + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 1<<20), 10<<20) + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + var item T + if err := json.Unmarshal(line, &item); err != nil { + return nil, err + } + result = append(result, item) + } + return result, scanner.Err() +} + +// countUserMemory returns the total number of per-user memory docs. +func countUserMemory(m map[string][]MemoryExport) int { + n := 0 + for _, docs := range m { + n += len(docs) + } + return n +} + +// unmarshalField decodes a JSON raw value into dest if present. +func unmarshalField[T any](cfg map[string]json.RawMessage, key string, dest *T) { + if raw, ok := cfg[key]; ok && len(raw) > 0 { + json.Unmarshal(raw, dest) //nolint:errcheck + } +} + +// rawOrNil returns the raw message if non-empty, else nil. +func rawOrNil(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return nil + } + return raw +} + +// importTenantID returns the tenant UUID from context, falling back to MasterTenantID. +func importTenantID(ctx context.Context) uuid.UUID { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return store.MasterTenantID + } + return tid +} + +// nullJSON returns nil if raw is empty (for JSONB nullable columns), otherwise returns raw. +func nullJSON(raw json.RawMessage) any { + if len(raw) == 0 { + return nil + } + return raw +} + +// coalesceJSON returns the raw message or '{}' if empty — for NOT NULL JSONB columns. +func coalesceJSON(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return json.RawMessage(`{}`) + } + return raw +} + +// nullStr converts a *string pointer to nil interface if the pointer is nil. +func nullStr(s *string) any { + if s == nil { + return nil + } + return *s +} + +// readNewSections parses extended archive entries into the importArchive. +// Skills/MCP/permissions sections from old archives are silently skipped (backward compat). +func readNewSections(arc *importArchive, entries map[string][]byte) error { + if data, ok := entries["cron/jobs.jsonl"]; ok { + items, err := parseJSONL[pg.CronJobExport](data) + if err != nil { + return fmt.Errorf("parse cron/jobs.jsonl: %w", err) + } + arc.cronJobs = items + } + if data, ok := entries["user_profiles.jsonl"]; ok { + items, err := parseJSONL[pg.UserProfileExport](data) + if err != nil { + return fmt.Errorf("parse user_profiles.jsonl: %w", err) + } + arc.userProfiles = items + } + if data, ok := entries["user_overrides.jsonl"]; ok { + items, err := parseJSONL[pg.UserOverrideExport](data) + if err != nil { + return fmt.Errorf("parse user_overrides.jsonl: %w", err) + } + arc.userOverrides = items + } + + // Phase 4: team section + if data, ok := entries["team/team.json"]; ok { + var t pg.TeamExport + if err := json.Unmarshal(data, &t); err != nil { + return fmt.Errorf("parse team/team.json: %w", err) + } + arc.teamMeta = &t + } + if data, ok := entries["team/members.jsonl"]; ok { + items, err := parseJSONL[pg.TeamMemberExport](data) + if err != nil { + return fmt.Errorf("parse team/members.jsonl: %w", err) + } + arc.teamMembers = items + } + if data, ok := entries["team/tasks.jsonl"]; ok { + items, err := parseJSONL[pg.TeamTaskExport](data) + if err != nil { + return fmt.Errorf("parse team/tasks.jsonl: %w", err) + } + arc.teamTasks = items + } + if data, ok := entries["team/comments.jsonl"]; ok { + items, err := parseJSONL[pg.TeamTaskCommentExport](data) + if err != nil { + return fmt.Errorf("parse team/comments.jsonl: %w", err) + } + arc.teamComments = items + } + if data, ok := entries["team/events.jsonl"]; ok { + items, err := parseJSONL[pg.TeamTaskEventExport](data) + if err != nil { + return fmt.Errorf("parse team/events.jsonl: %w", err) + } + arc.teamEvents = items + } + if data, ok := entries["team/links.jsonl"]; ok { + items, err := parseJSONL[pg.AgentLinkExport](data) + if err != nil { + return fmt.Errorf("parse team/links.jsonl: %w", err) + } + arc.teamLinks = items + } + + // Team workspace files: "team/workspace/" → arc.teamWorkspace + arc.teamWorkspace = make(map[string][]byte) + for name, data := range entries { + if rel := strings.TrimPrefix(name, "team/workspace/"); rel != name && rel != "" { + arc.teamWorkspace[rel] = data + } + } + + return nil +} diff --git a/internal/http/agents_import_team.go b/internal/http/agents_import_team.go new file mode 100644 index 00000000..0e246261 --- /dev/null +++ b/internal/http/agents_import_team.go @@ -0,0 +1,252 @@ +package http + +import ( + "context" + "fmt" + "log/slog" + "path/filepath" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// importTeamSection creates a team, members, tasks, comments, events, links, +// and team workspace for the imported agent (which becomes the team lead). +func (h *AgentsHandler) importTeamSection(ctx context.Context, ag *store.AgentData, arc *importArchive, progressFn func(ProgressEvent)) error { + tid := importTenantID(ctx) + userID := store.UserIDFromContext(ctx) + + // Check if agent is already a team lead — skip to prevent duplicate teams + var existingTeam bool + _ = h.db.QueryRowContext(ctx, + "SELECT EXISTS(SELECT 1 FROM agent_teams WHERE lead_agent_id = $1 AND tenant_id = $2)", + ag.ID, tid, + ).Scan(&existingTeam) + if existingTeam { + slog.Info("import: agent already has a team, skipping team import", "agent_id", ag.ID) + if progressFn != nil { + progressFn(ProgressEvent{Phase: "team", Status: "done", Detail: "skipped (team exists)"}) + } + return nil + } + + // Create team with new UUID + teamID := uuid.Must(uuid.NewV7()) + _, err := h.db.ExecContext(ctx, + `INSERT INTO agent_teams (id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW(), $8)`, + teamID, arc.teamMeta.Name, ag.ID, + arc.teamMeta.Description, arc.teamMeta.Status, + coalesceJSON(arc.teamMeta.Settings), userID, tid, + ) + if err != nil { + return fmt.Errorf("create team: %w", err) + } + + // Add lead as member + if _, err = h.db.ExecContext(ctx, + `INSERT INTO agent_team_members (team_id, agent_id, role, tenant_id, joined_at) + VALUES ($1, $2, 'lead', $3, NOW()) + ON CONFLICT (team_id, agent_id) DO NOTHING`, + teamID, ag.ID, tid, + ); err != nil { + slog.Warn("import.team: add lead member", "error", err) + } + + // Resolve agent_key → agent_id for all referenced keys + agentKeyToID := h.buildAgentKeyMap(ctx, tid, arc) + // Always include the importing agent itself + agentKeyToID[ag.AgentKey] = ag.ID + + // Insert non-lead members + for _, m := range arc.teamMembers { + memberID, ok := agentKeyToID[m.AgentKey] + if !ok { + slog.Info("import.team: member not found, skipping", "agent_key", m.AgentKey) + continue + } + if _, err = h.db.ExecContext(ctx, + `INSERT INTO agent_team_members (team_id, agent_id, role, tenant_id, joined_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (team_id, agent_id) DO NOTHING`, + teamID, memberID, m.Role, tid, + ); err != nil { + slog.Warn("import.team: insert member", "agent_key", m.AgentKey, "error", err) + } + } + + // Insert tasks — collect new IDs by index for parent/comment/event mapping + taskIDByIdx := make([]uuid.UUID, len(arc.teamTasks)) + for i, t := range arc.teamTasks { + newID := uuid.Must(uuid.NewV7()) + taskIDByIdx[i] = newID + + var ownerID *uuid.UUID + if t.OwnerAgentKey != nil { + if id, ok := agentKeyToID[*t.OwnerAgentKey]; ok { + ownerID = &id + } + } + var createdByID *uuid.UUID + if t.CreatedByKey != nil { + if id, ok := agentKeyToID[*t.CreatedByKey]; ok { + createdByID = &id + } + } + + if _, err = h.db.ExecContext(ctx, + `INSERT INTO team_tasks + (id, team_id, subject, description, status, priority, result, metadata, + task_type, task_number, identifier, owner_agent_id, created_by_agent_id, + assignee_user_id, progress_percent, progress_step, tenant_id, created_at, updated_at) + VALUES ($1,$2,$3,$4,'pending',$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW(),NOW())`, + newID, teamID, t.Subject, t.Description, + t.Priority, nullStr(t.Result), nullJSON(t.Metadata), + t.TaskType, t.TaskNumber, t.Identifier, + ownerID, createdByID, t.AssigneeUserID, + nullInt(t.ProgressPercent), nullStr(t.ProgressStep), + tid, + ); err != nil { + slog.Warn("import.team: insert task", "subject", t.Subject, "error", err) + } + } + + // Second pass: wire parent_id now that all task IDs exist + for i, t := range arc.teamTasks { + if t.ParentIdx == nil { + continue + } + pidx := *t.ParentIdx + if pidx < 0 || pidx >= len(taskIDByIdx) { + continue + } + if _, err = h.db.ExecContext(ctx, + `UPDATE team_tasks SET parent_id = $1 WHERE id = $2`, + taskIDByIdx[pidx], taskIDByIdx[i], + ); err != nil { + slog.Warn("import.team: set parent_id", "child_idx", i, "error", err) + } + } + + // Insert comments + for _, c := range arc.teamComments { + if c.TaskIdx < 0 || c.TaskIdx >= len(taskIDByIdx) { + continue + } + taskID := taskIDByIdx[c.TaskIdx] + + var agentID *uuid.UUID + if c.AgentKey != nil { + if id, ok := agentKeyToID[*c.AgentKey]; ok { + agentID = &id + } + } + if _, err = h.db.ExecContext(ctx, + `INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, comment_type, metadata, tenant_id, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())`, + uuid.Must(uuid.NewV7()), taskID, agentID, c.UserID, + c.Content, c.CommentType, nullJSON(c.Metadata), tid, + ); err != nil { + slog.Warn("import.team: insert comment", "task_id", taskID, "error", err) + } + } + + // Insert events + for _, ev := range arc.teamEvents { + if ev.TaskIdx < 0 || ev.TaskIdx >= len(taskIDByIdx) { + continue + } + taskID := taskIDByIdx[ev.TaskIdx] + if _, err = h.db.ExecContext(ctx, + `INSERT INTO team_task_events (id, task_id, event_type, actor_type, actor_id, data, tenant_id, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,NOW())`, + uuid.Must(uuid.NewV7()), taskID, + ev.EventType, ev.ActorType, ev.ActorID, nullJSON(ev.Data), tid, + ); err != nil { + slog.Warn("import.team: insert event", "task_id", taskID, "error", err) + } + } + + // Insert agent_links + for _, l := range arc.teamLinks { + srcID, srcOK := agentKeyToID[l.SourceAgentKey] + tgtID, tgtOK := agentKeyToID[l.TargetAgentKey] + if !srcOK || !tgtOK { + slog.Info("import.team: agent_link skipped — agent not found", + "source", l.SourceAgentKey, "target", l.TargetAgentKey) + continue + } + if _, err = h.db.ExecContext(ctx, + `INSERT INTO agent_links (id, source_agent_id, target_agent_id, direction, description, created_by, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT DO NOTHING`, + uuid.Must(uuid.NewV7()), srcID, tgtID, + l.Direction, l.Description, userID, tid, + ); err != nil { + slog.Warn("import.team: insert link", "error", err) + } + } + + // Extract team workspace files + if len(arc.teamWorkspace) > 0 && h.dataDir != "" { + wsPath := filepath.Join(config.ExpandHome(h.dataDir), "teams", teamID.String()) + imported, wsErr := extractWorkspaceFiles(wsPath, arc.teamWorkspace, false) + if wsErr != nil { + slog.Warn("import.team: workspace extraction failed", "path", wsPath, "error", wsErr) + } + if progressFn != nil { + progressFn(ProgressEvent{Phase: "team_workspace", Status: "done", Current: imported, Total: len(arc.teamWorkspace)}) + } + } + + if progressFn != nil { + progressFn(ProgressEvent{Phase: "team", Status: "done", Current: len(arc.teamTasks), Total: len(arc.teamTasks)}) + } + return nil +} + +// buildAgentKeyMap resolves all agent_keys referenced in the team section to UUIDs. +func (h *AgentsHandler) buildAgentKeyMap(ctx context.Context, tid uuid.UUID, arc *importArchive) map[string]uuid.UUID { + keys := make(map[string]struct{}) + for _, m := range arc.teamMembers { + keys[m.AgentKey] = struct{}{} + } + for _, t := range arc.teamTasks { + if t.OwnerAgentKey != nil { + keys[*t.OwnerAgentKey] = struct{}{} + } + if t.CreatedByKey != nil { + keys[*t.CreatedByKey] = struct{}{} + } + } + for _, c := range arc.teamComments { + if c.AgentKey != nil { + keys[*c.AgentKey] = struct{}{} + } + } + for _, l := range arc.teamLinks { + keys[l.SourceAgentKey] = struct{}{} + keys[l.TargetAgentKey] = struct{}{} + } + + out := make(map[string]uuid.UUID, len(keys)) + for key := range keys { + var id uuid.UUID + if err := h.db.QueryRowContext(ctx, + `SELECT id FROM agents WHERE agent_key = $1 AND tenant_id = $2`, key, tid, + ).Scan(&id); err == nil { + out[key] = id + } + } + return out +} + +// nullInt returns nil if n is nil, otherwise the int value — for nullable int columns. +func nullInt(n *int) any { + if n == nil { + return nil + } + return *n +} diff --git a/internal/http/agents_workspace.go b/internal/http/agents_workspace.go new file mode 100644 index 00000000..2ba104d7 --- /dev/null +++ b/internal/http/agents_workspace.go @@ -0,0 +1,133 @@ +package http + +import ( + "archive/tar" + "context" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strings" +) + +const maxWorkspaceFileSize = 50 << 20 // 50MB per file + +// exportWorkspaceFiles walks wsPath and adds each file to tw under the "workspace/" prefix. +// Skips directories, hidden files (dot-prefixed), symlinks, and files exceeding maxWorkspaceFileSize. +func (h *AgentsHandler) exportWorkspaceFiles(ctx context.Context, tw *tar.Writer, wsPath string, progressFn func(ProgressEvent)) (int, int64, error) { + return h.exportWorkspaceFilesWithPrefix(ctx, tw, wsPath, "workspace/", progressFn) +} + +// exportWorkspaceFilesWithPrefix walks wsPath and adds each file to tw under tarPrefix. +// Skips directories, hidden files (dot-prefixed), symlinks, and files exceeding maxWorkspaceFileSize. +func (h *AgentsHandler) exportWorkspaceFilesWithPrefix(ctx context.Context, tw *tar.Writer, wsPath, tarPrefix string, progressFn func(ProgressEvent)) (int, int64, error) { + info, err := os.Stat(wsPath) + if err != nil || !info.IsDir() { + return 0, 0, nil // no workspace dir = nothing to export + } + + var count int + var totalBytes int64 + + walkErr := filepath.WalkDir(wsPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil // skip unreadable entries + } + if ctx.Err() != nil { + return ctx.Err() + } + + if d.IsDir() { + return nil + } + if strings.HasPrefix(d.Name(), ".") { + return nil + } + if d.Type()&fs.ModeSymlink != 0 { + return nil + } + + fi, err := d.Info() + if err != nil { + return nil + } + if fi.Size() > maxWorkspaceFileSize { + slog.Debug("export: skipping large workspace file", "path", path, "size", fi.Size()) + return nil + } + + rel, err := filepath.Rel(wsPath, path) + if err != nil { + return nil + } + + data, err := os.ReadFile(path) + if err != nil { + slog.Warn("export: failed to read workspace file", "path", path, "error", err) + return nil + } + + tarPath := tarPrefix + sanitizeRelPath(filepath.ToSlash(rel)) + if err := addToTar(tw, tarPath, data); err != nil { + return err + } + + count++ + totalBytes += fi.Size() + if progressFn != nil && count%10 == 0 { + progressFn(ProgressEvent{Phase: "workspace", Status: "running", Current: count}) + } + return nil + }) + + if progressFn != nil { + progressFn(ProgressEvent{Phase: "workspace", Status: "done", Current: count, Total: count}) + } + return count, totalBytes, walkErr +} + +// extractWorkspaceFiles writes files from the archive into baseDir. +// Security: rejects path traversal and absolute paths. +// When overwrite is false, existing files are skipped. +func extractWorkspaceFiles(baseDir string, files map[string][]byte, overwrite bool) (int, error) { + if err := os.MkdirAll(baseDir, 0755); err != nil { + return 0, fmt.Errorf("create workspace dir: %w", err) + } + + // Ensure baseDir ends with separator for prefix check + baseDirClean := filepath.Clean(baseDir) + string(filepath.Separator) + + var count int + for rel, data := range files { + clean := filepath.Clean(rel) + if strings.Contains(clean, "..") { + continue + } + if filepath.IsAbs(clean) { + continue + } + + target := filepath.Join(baseDir, clean) + // Verify target stays inside baseDir after path resolution + if !strings.HasPrefix(filepath.Clean(target)+string(filepath.Separator), baseDirClean) { + continue + } + + if !overwrite { + if _, err := os.Stat(target); err == nil { + continue // skip existing + } + } + + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + continue + } + + if err := os.WriteFile(target, data, 0644); err != nil { + return count, fmt.Errorf("write %s: %w", rel, err) + } + count++ + } + return count, nil +} diff --git a/internal/http/mcp.go b/internal/http/mcp.go index c65e8000..9112db33 100644 --- a/internal/http/mcp.go +++ b/internal/http/mcp.go @@ -1,6 +1,7 @@ package http import ( + "database/sql" "encoding/json" "log/slog" "net/http" @@ -30,6 +31,7 @@ type MCPHandler struct { msgBus *bus.MessageBus mgr MCPToolLister // optional, nil when Manager not available poolEvictor MCPPoolEvictor // optional, nil when pool not available + db *sql.DB // for export/import direct queries } // NewMCPHandler creates a handler for MCP server management endpoints. @@ -82,6 +84,10 @@ func (h *MCPHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /v1/mcp/requests", h.auth(h.handleCreateRequest)) mux.HandleFunc("GET /v1/mcp/requests", h.auth(h.handleListPendingRequests)) mux.HandleFunc("POST /v1/mcp/requests/{id}/review", h.adminAuth(h.handleReviewRequest)) + // Export / Import (admin+) + mux.HandleFunc("GET /v1/mcp/export/preview", h.adminAuth(h.handleMCPExportPreview)) + mux.HandleFunc("GET /v1/mcp/export", h.adminAuth(h.handleMCPExport)) + mux.HandleFunc("POST /v1/mcp/import", h.adminAuth(h.handleMCPImport)) } func (h *MCPHandler) auth(next http.HandlerFunc) http.HandlerFunc { diff --git a/internal/http/mcp_export.go b/internal/http/mcp_export.go new file mode 100644 index 00000000..bf44a963 --- /dev/null +++ b/internal/http/mcp_export.go @@ -0,0 +1,152 @@ +package http + +import ( + "archive/tar" + "compress/gzip" + "context" + "database/sql" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" +) + +// SetDB injects the raw DB handle needed for MCP export/import direct queries. +func (h *MCPHandler) SetDB(db *sql.DB) { + h.db = db +} + +// handleMCPExportPreview returns MCP export counts without building the archive. +func (h *MCPHandler) handleMCPExportPreview(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + if h.db == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "db not configured")}) + return + } + + preview, err := pg.ExportMCPPreview(r.Context(), h.db) + if err != nil { + slog.Error("mcp.export.preview", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError)}) + return + } + writeJSON(w, http.StatusOK, preview) +} + +// handleMCPExport builds and streams (or SSE-wraps) a MCP servers tar.gz archive. +func (h *MCPHandler) handleMCPExport(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + userID := store.UserIDFromContext(r.Context()) + + if h.db == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "db not configured")}) + return + } + + stream := r.URL.Query().Get("stream") == "true" + fileName := fmt.Sprintf("mcp-servers-%s.tar.gz", time.Now().UTC().Format("20060102")) + + if stream { + flusher := initSSE(w) + if flusher == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "streaming not supported"}) + return + } + + tmpFile, err := os.CreateTemp("", "goclaw-mcp-export-*.tar.gz") + if err != nil { + sendSSE(w, flusher, "error", ProgressEvent{Phase: "init", Status: "error", Detail: "failed to create temp file"}) + return + } + tmpPath := tmpFile.Name() + + progressFn := func(ev ProgressEvent) { sendSSE(w, flusher, "progress", ev) } + buildErr := h.writeMCPExportArchive(r.Context(), tmpFile, progressFn) + tmpFile.Close() + + if buildErr != nil { + slog.Error("mcp.export.sse", "error", buildErr) + sendSSE(w, flusher, "error", ProgressEvent{Phase: "archive", Status: "error", Detail: buildErr.Error()}) + os.Remove(tmpPath) + return + } + + token := storeExportToken("mcp", userID, tmpPath, fileName) + sendSSE(w, flusher, "complete", map[string]string{ + "download_url": "/v1/export/download/" + token, + }) + return + } + + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName)) + if err := h.writeMCPExportArchive(r.Context(), w, nil); err != nil { + slog.Error("mcp.export.direct", "error", err) + } +} + +// writeMCPExportArchive builds the MCP tar.gz: servers.jsonl + grants.jsonl. +func (h *MCPHandler) writeMCPExportArchive(ctx context.Context, w io.Writer, progressFn func(ProgressEvent)) error { + lw := &limitedWriter{w: w, limit: maxExportSize} + gw := gzip.NewWriter(lw) + tw := tar.NewWriter(gw) + + servers, err := pg.ExportMCPServers(ctx, h.db) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("query mcp servers: %w", err) + } + + if len(servers) > 0 { + data, err := marshalJSONL(servers) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal servers: %w", err) + } + if err := addToTar(tw, "servers.jsonl", data); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write servers.jsonl: %w", err) + } + } + + if progressFn != nil { + progressFn(ProgressEvent{Phase: "servers", Status: "done", Current: len(servers), Total: len(servers), Detail: fmt.Sprintf("%d servers exported", len(servers))}) + } + + grants, err := pg.ExportMCPGrantsWithKeys(ctx, h.db) + if err != nil { + slog.Warn("mcp.export: query grants failed", "error", err) + } + if len(grants) > 0 { + data, err := marshalJSONL(grants) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal grants: %w", err) + } + if err := addToTar(tw, "grants.jsonl", data); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write grants.jsonl: %w", err) + } + } + + if progressFn != nil { + progressFn(ProgressEvent{Phase: "grants", Status: "done", Current: len(grants), Total: len(grants), Detail: fmt.Sprintf("%d agent grants", len(grants))}) + } + + if err := tw.Close(); err != nil { + gw.Close() + return fmt.Errorf("close tar: %w", err) + } + return gw.Close() +} diff --git a/internal/http/mcp_import.go b/internal/http/mcp_import.go new file mode 100644 index 00000000..ca99ac60 --- /dev/null +++ b/internal/http/mcp_import.go @@ -0,0 +1,163 @@ +package http + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" +) + +// handleMCPImport imports a MCP servers archive (POST /v1/mcp/import). +func (h *MCPHandler) handleMCPImport(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + userID := store.UserIDFromContext(r.Context()) + + if h.db == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "db not configured")}) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxImportBodySize) + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "multipart parse: "+err.Error())}) + return + } + + f, _, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "missing 'file' field")}) + return + } + defer f.Close() + + stream := r.URL.Query().Get("stream") == "true" + if stream { + flusher := initSSE(w) + if flusher == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "streaming not supported"}) + return + } + progressFn := func(ev ProgressEvent) { sendSSE(w, flusher, "progress", ev) } + summary, importErr := h.doMCPImport(r.Context(), f, userID, progressFn) + if importErr != nil { + sendSSE(w, flusher, "error", ProgressEvent{Phase: "import", Status: "error", Detail: importErr.Error()}) + return + } + sendSSE(w, flusher, "complete", summary) + return + } + + summary, err := h.doMCPImport(r.Context(), f, userID, nil) + if err != nil { + slog.Error("mcp.import", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + writeJSON(w, http.StatusCreated, summary) +} + +// MCPImportSummary is returned after a successful MCP import. +type MCPImportSummary struct { + ServersImported int `json:"servers_imported"` + ServersSkipped int `json:"servers_skipped"` + GrantsApplied int `json:"grants_applied"` +} + +// doMCPImport parses the MCP tar.gz and creates servers + grants. +func (h *MCPHandler) doMCPImport(ctx context.Context, r io.Reader, userID string, progressFn func(ProgressEvent)) (*MCPImportSummary, error) { + entries, err := readTarGzEntries(r) + if err != nil { + return nil, err + } + + summary := &MCPImportSummary{} + + // Parse servers + var servers []pg.MCPServerExport + if raw, ok := entries["servers.jsonl"]; ok { + servers, err = parseJSONL[pg.MCPServerExport](raw) + if err != nil { + return nil, fmt.Errorf("parse servers.jsonl: %w", err) + } + } + + // Parse grants + var grants []pg.MCPGrantWithKey + if raw, ok := entries["grants.jsonl"]; ok { + grants, err = parseJSONL[pg.MCPGrantWithKey](raw) + if err != nil { + slog.Warn("mcp.import: parse grants.jsonl failed", "error", err) + } + } + + // Import servers — build name → uuid.UUID map for grant wiring + tid := importTenantID(ctx) + serverNameToUUID := make(map[string]uuid.UUID, len(servers)) + + for i, srv := range servers { + if progressFn != nil { + progressFn(ProgressEvent{Phase: "server", Status: "running", Current: i + 1, Total: len(servers), Detail: srv.Name}) + } + id, created, err := pg.ImportMCPServer(ctx, h.db, srv, userID) + if err != nil { + slog.Warn("mcp.import: create server", "name", srv.Name, "error", err) + continue + } + serverNameToUUID[srv.Name] = id + if created { + summary.ServersImported++ + } else { + summary.ServersSkipped++ + } + if progressFn != nil { + progressFn(ProgressEvent{Phase: "server", Status: "done", Detail: srv.Name}) + } + } + + h.emitCacheInvalidate() + + // Apply grants + for i, g := range grants { + if progressFn != nil { + progressFn(ProgressEvent{Phase: "grant", Status: "running", Current: i + 1, Total: len(grants)}) + } + + serverID, ok := serverNameToUUID[g.ServerName] + if !ok { + // Server may have pre-existed — look it up (tenant-scoped) + if err := h.db.QueryRowContext(ctx, + "SELECT id FROM mcp_servers WHERE name = $1 AND tenant_id = $2", g.ServerName, tid, + ).Scan(&serverID); err != nil { + slog.Warn("mcp.import.grant: server not found", "server", g.ServerName) + continue + } + } + + var agentID uuid.UUID + if err := h.db.QueryRowContext(ctx, + "SELECT id FROM agents WHERE agent_key = $1 AND tenant_id = $2", g.AgentKey, tid, + ).Scan(&agentID); err != nil { + slog.Warn("mcp.import.grant: agent not found", "key", g.AgentKey) + continue + } + + if err := pg.ImportMCPGrant(ctx, h.db, serverID, agentID, g, userID); err != nil { + slog.Warn("mcp.import.grant: insert", "server", g.ServerName, "agent", g.AgentKey, "error", err) + continue + } + summary.GrantsApplied++ + } + + if progressFn != nil { + progressFn(ProgressEvent{Phase: "grants", Status: "done", Current: summary.GrantsApplied, Total: len(grants)}) + } + + return summary, nil +} diff --git a/internal/http/skills.go b/internal/http/skills.go index d28430fb..f1df671e 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -2,6 +2,7 @@ package http import ( "context" + "database/sql" "encoding/json" "log/slog" "net/http" @@ -30,6 +31,7 @@ type SkillsHandler struct { msgBus *bus.MessageBus tenantCfgStore store.SkillTenantConfigStore tenantStore store.TenantStore + db *sql.DB // for export/import direct queries } // NewSkillsHandler creates a handler for skill management endpoints. @@ -84,6 +86,10 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) { // Per-tenant overrides (admin+) mux.HandleFunc("PUT /v1/skills/{id}/tenant-config", h.adminMiddleware(h.handleSetTenantConfig)) mux.HandleFunc("DELETE /v1/skills/{id}/tenant-config", h.adminMiddleware(h.handleDeleteTenantConfig)) + // Export / Import (admin+) + mux.HandleFunc("GET /v1/skills/export/preview", h.adminMiddleware(h.handleSkillsExportPreview)) + mux.HandleFunc("GET /v1/skills/export", h.adminMiddleware(h.handleSkillsExport)) + mux.HandleFunc("POST /v1/skills/import", h.adminMiddleware(h.handleSkillsImport)) } func (h *SkillsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { diff --git a/internal/http/skills_export.go b/internal/http/skills_export.go new file mode 100644 index 00000000..d4f688a6 --- /dev/null +++ b/internal/http/skills_export.go @@ -0,0 +1,190 @@ +package http + +import ( + "archive/tar" + "compress/gzip" + "context" + "database/sql" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" +) + +// SetDB injects the raw DB handle needed for export/import direct queries. +func (h *SkillsHandler) SetDB(db *sql.DB) { + h.db = db +} + +// handleSkillsExportPreview returns skill export counts without building the archive. +func (h *SkillsHandler) handleSkillsExportPreview(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + if h.db == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "db not configured")}) + return + } + + preview, err := pg.ExportSkillsPreview(r.Context(), h.db) + if err != nil { + slog.Error("skills.export.preview", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError)}) + return + } + writeJSON(w, http.StatusOK, preview) +} + +// handleSkillsExport builds and streams (or SSE-wraps) a skills tar.gz archive. +func (h *SkillsHandler) handleSkillsExport(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + userID := store.UserIDFromContext(r.Context()) + + if h.db == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "db not configured")}) + return + } + + stream := r.URL.Query().Get("stream") == "true" + fileName := fmt.Sprintf("skills-%s.tar.gz", time.Now().UTC().Format("20060102")) + + if stream { + flusher := initSSE(w) + if flusher == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "streaming not supported"}) + return + } + + tmpFile, err := os.CreateTemp("", "goclaw-skills-export-*.tar.gz") + if err != nil { + sendSSE(w, flusher, "error", ProgressEvent{Phase: "init", Status: "error", Detail: "failed to create temp file"}) + return + } + tmpPath := tmpFile.Name() + + progressFn := func(ev ProgressEvent) { sendSSE(w, flusher, "progress", ev) } + buildErr := h.writeSkillsExportArchive(r.Context(), tmpFile, progressFn) + tmpFile.Close() + + if buildErr != nil { + slog.Error("skills.export.sse", "error", buildErr) + sendSSE(w, flusher, "error", ProgressEvent{Phase: "archive", Status: "error", Detail: buildErr.Error()}) + os.Remove(tmpPath) + return + } + + token := storeExportToken("skills", userID, tmpPath, fileName) + sendSSE(w, flusher, "complete", map[string]string{ + "download_url": "/v1/export/download/" + token, + }) + return + } + + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName)) + if err := h.writeSkillsExportArchive(r.Context(), w, nil); err != nil { + slog.Error("skills.export.direct", "error", err) + } +} + +// writeSkillsExportArchive builds the skills tar.gz: skills/{slug}/metadata.json + SKILL.md + grants.jsonl per skill. +func (h *SkillsHandler) writeSkillsExportArchive(ctx context.Context, w io.Writer, progressFn func(ProgressEvent)) error { + lw := &limitedWriter{w: w, limit: maxExportSize} + gw := gzip.NewWriter(lw) + tw := tar.NewWriter(gw) + + skills, err := pg.ExportCustomSkills(ctx, h.db) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("query custom skills: %w", err) + } + + for i, sk := range skills { + if progressFn != nil { + progressFn(ProgressEvent{Phase: "skills", Status: "running", Current: i + 1, Total: len(skills), Detail: sk.Slug}) + } + + prefix := "skills/" + sanitizeName(sk.Slug) + "/" + + // metadata.json — strip FilePath from exported metadata + type exportMeta struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description,omitempty"` + Visibility string `json:"visibility"` + Version int `json:"version"` + Tags []string `json:"tags,omitempty"` + } + meta := exportMeta{ + ID: sk.ID, + Name: sk.Name, + Slug: sk.Slug, + Description: sk.Description, + Visibility: sk.Visibility, + Version: sk.Version, + Tags: sk.Tags, + } + metaJSON, err := jsonIndent(meta) + if err != nil { + slog.Warn("skills.export: marshal metadata", "slug", sk.Slug, "error", err) + continue + } + if err := addToTar(tw, prefix+"metadata.json", metaJSON); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write %smetadata.json: %w", prefix, err) + } + + // SKILL.md — read from filesystem + if sk.FilePath != "" { + fullPath := config.ExpandHome(sk.FilePath) + if data, err := os.ReadFile(fullPath); err == nil { + if err := addToTar(tw, prefix+"SKILL.md", data); err != nil { + slog.Warn("skills.export: write SKILL.md", "slug", sk.Slug, "error", err) + } + } else { + slog.Warn("skills.export: read SKILL.md", "slug", sk.Slug, "path", fullPath, "error", err) + } + } + + // grants.jsonl + skillID, err := uuid.Parse(sk.ID) + if err != nil { + slog.Warn("skills.export: invalid skill id", "id", sk.ID) + continue + } + grants, err := pg.ExportSkillGrantsWithAgentKey(ctx, h.db, skillID) + if err != nil { + slog.Warn("skills.export: query grants", "slug", sk.Slug, "error", err) + } + if len(grants) > 0 { + data, err := marshalJSONL(grants) + if err != nil { + slog.Warn("skills.export: marshal grants", "slug", sk.Slug, "error", err) + } else if err := addToTar(tw, prefix+"grants.jsonl", data); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write %sgrants.jsonl: %w", prefix, err) + } + } + } + + if progressFn != nil { + progressFn(ProgressEvent{Phase: "skills", Status: "done", Current: len(skills), Total: len(skills), Detail: fmt.Sprintf("%d skills exported", len(skills))}) + } + + if err := tw.Close(); err != nil { + gw.Close() + return fmt.Errorf("close tar: %w", err) + } + return gw.Close() +} diff --git a/internal/http/skills_import.go b/internal/http/skills_import.go new file mode 100644 index 00000000..be80f568 --- /dev/null +++ b/internal/http/skills_import.go @@ -0,0 +1,243 @@ +package http + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" +) + +// handleSkillsImport imports a skills archive (POST /v1/skills/import). +func (h *SkillsHandler) handleSkillsImport(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + userID := store.UserIDFromContext(r.Context()) + + if h.db == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "db not configured")}) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxImportBodySize) + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "multipart parse: "+err.Error())}) + return + } + + f, _, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "missing 'file' field")}) + return + } + defer f.Close() + + stream := r.URL.Query().Get("stream") == "true" + if stream { + flusher := initSSE(w) + if flusher == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "streaming not supported"}) + return + } + progressFn := func(ev ProgressEvent) { sendSSE(w, flusher, "progress", ev) } + summary, importErr := h.doSkillsImport(r.Context(), f, userID, progressFn) + if importErr != nil { + sendSSE(w, flusher, "error", ProgressEvent{Phase: "import", Status: "error", Detail: importErr.Error()}) + return + } + sendSSE(w, flusher, "complete", summary) + return + } + + summary, err := h.doSkillsImport(r.Context(), f, userID, nil) + if err != nil { + slog.Error("skills.import", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + writeJSON(w, http.StatusCreated, summary) +} + +// SkillsImportSummary is returned after a successful skills import. +type SkillsImportSummary struct { + SkillsImported int `json:"skills_imported"` + SkillsSkipped int `json:"skills_skipped"` + GrantsApplied int `json:"grants_applied"` +} + +// doSkillsImport parses the skills tar.gz and creates skills + writes files + applies grants. +func (h *SkillsHandler) doSkillsImport(ctx context.Context, r io.Reader, userID string, progressFn func(ProgressEvent)) (*SkillsImportSummary, error) { + entries, err := readTarGzEntries(r) + if err != nil { + return nil, err + } + + // Group entries by skill slug: skills/{slug}/... + type skillEntry struct { + metadata []byte + skillMD []byte + grants []byte + } + bySlug := make(map[string]*skillEntry) + + for name, data := range entries { + if !strings.HasPrefix(name, "skills/") { + continue + } + rest := strings.TrimPrefix(name, "skills/") + slashIdx := strings.Index(rest, "/") + if slashIdx < 0 { + continue + } + slug := rest[:slashIdx] + file := rest[slashIdx+1:] + if slug == "" || file == "" { + continue + } + if bySlug[slug] == nil { + bySlug[slug] = &skillEntry{} + } + switch file { + case "metadata.json": + bySlug[slug].metadata = data + case "SKILL.md": + bySlug[slug].skillMD = data + case "grants.jsonl": + bySlug[slug].grants = data + } + } + + summary := &SkillsImportSummary{} + skillsDir := h.tenantSkillsDirForImport(ctx) + + for slug, entry := range bySlug { + if entry.metadata == nil { + slog.Warn("skills.import: missing metadata.json", "slug", slug) + continue + } + + var meta struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description,omitempty"` + Visibility string `json:"visibility"` + Version int `json:"version"` + Tags []string `json:"tags,omitempty"` + } + if err := json.Unmarshal(entry.metadata, &meta); err != nil { + slog.Warn("skills.import: parse metadata", "slug", slug, "error", err) + continue + } + + if progressFn != nil { + progressFn(ProgressEvent{Phase: "skill", Status: "running", Detail: slug}) + } + + skillDir := filepath.Join(skillsDir, sanitizeName(slug)) + skillFilePath := filepath.Join(skillDir, "SKILL.md") + + // Check if slug already exists in this tenant before writing files + tid := tenantIDForSkillImport(ctx) + var existing bool + _ = h.db.QueryRowContext(ctx, + "SELECT EXISTS(SELECT 1 FROM skills WHERE slug = $1 AND tenant_id = $2)", slug, tid, + ).Scan(&existing) + + var skillID uuid.UUID + if existing { + _ = h.db.QueryRowContext(ctx, + "SELECT id FROM skills WHERE slug = $1 AND tenant_id = $2", slug, tid, + ).Scan(&skillID) + summary.SkillsSkipped++ + } else { + // Write SKILL.md to filesystem only for new skills + if err := os.MkdirAll(skillDir, 0755); err != nil { + slog.Warn("skills.import: mkdir skill dir", "slug", slug, "error", err) + continue + } + if entry.skillMD != nil { + if err := os.WriteFile(skillFilePath, entry.skillMD, 0644); err != nil { + slog.Warn("skills.import: write SKILL.md", "slug", slug, "error", err) + } + } + + skillID = uuid.Must(uuid.NewV7()) + visibility := meta.Visibility + if visibility == "" { + visibility = "internal" + } + version := meta.Version + if version <= 0 { + version = 1 + } + _, err := h.db.ExecContext(ctx, + `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, + is_system, file_path, file_size, tenant_id, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,'active',false,$8,0,$9,NOW(),NOW())`, + skillID, meta.Name, slug, meta.Description, + userID, visibility, version, skillFilePath, tid, + ) + if err != nil { + slog.Warn("skills.import: insert skill", "slug", slug, "error", err) + continue + } + summary.SkillsImported++ + } + + // Apply grants + if entry.grants != nil && skillID != uuid.Nil { + grants, err := parseJSONL[pg.SkillGrantWithKey](entry.grants) + if err != nil { + slog.Warn("skills.import: parse grants", "slug", slug, "error", err) + } + for _, g := range grants { + var agentID uuid.UUID + if err := h.db.QueryRowContext(ctx, + "SELECT id FROM agents WHERE agent_key = $1 AND tenant_id = $2", g.AgentKey, tid, + ).Scan(&agentID); err != nil { + slog.Warn("skills.import: agent not found for grant", "key", g.AgentKey) + continue + } + if err := pg.ImportSkillGrant(ctx, h.db, skillID, agentID, g.PinnedVersion, userID); err != nil { + slog.Warn("skills.import: apply grant", "slug", slug, "agent", g.AgentKey, "error", err) + continue + } + summary.GrantsApplied++ + } + } + + if progressFn != nil { + progressFn(ProgressEvent{Phase: "skill", Status: "done", Detail: slug}) + } + } + + // Bump skills version cache + h.skills.BumpVersion() + return summary, nil +} + +// tenantSkillsDirForImport returns the skills filesystem directory for import, scoped to tenant. +func (h *SkillsHandler) tenantSkillsDirForImport(ctx context.Context) string { + tid := store.TenantIDFromContext(ctx) + slug := store.TenantSlugFromContext(ctx) + return config.TenantSkillsStoreDir(h.dataDir, tid, slug) +} + +// tenantIDForSkillImport returns tenant UUID for skill INSERT, falling back to MasterTenantID. +func tenantIDForSkillImport(ctx context.Context) uuid.UUID { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return store.MasterTenantID + } + return tid +} + diff --git a/internal/http/sse.go b/internal/http/sse.go new file mode 100644 index 00000000..0e47feaf --- /dev/null +++ b/internal/http/sse.go @@ -0,0 +1,42 @@ +package http + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// ProgressEvent describes a single SSE progress update for long-running operations. +// Used by export, import, and any future streaming endpoints. +type ProgressEvent struct { + Phase string `json:"phase"` + Status string `json:"status"` // "running", "done", "error" + Detail string `json:"detail,omitempty"` + Current int `json:"current,omitempty"` + Total int `json:"total,omitempty"` +} + +// sendSSE writes a named SSE event with JSON payload and flushes. +// The event format follows the standard SSE spec: "event: \ndata: \n\n". +func sendSSE(w http.ResponseWriter, flusher http.Flusher, event string, data interface{}) { + jsonData, err := json.Marshal(data) + if err != nil { + return + } + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, jsonData) + flusher.Flush() +} + +// initSSE sets standard headers for an SSE response and writes the 200 status. +// Returns the Flusher or nil if streaming is not supported. +func initSSE(w http.ResponseWriter) http.Flusher { + flusher, ok := w.(http.Flusher) + if !ok { + return nil + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + return flusher +} diff --git a/internal/http/teams_export.go b/internal/http/teams_export.go new file mode 100644 index 00000000..942c4f28 --- /dev/null +++ b/internal/http/teams_export.go @@ -0,0 +1,528 @@ +package http + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// TeamExportManifest describes the contents of a team export archive. +type TeamExportManifest struct { + Version int `json:"version"` + Format string `json:"format"` + ExportedAt string `json:"exported_at"` + ExportedBy string `json:"exported_by"` + TeamName string `json:"team_name"` + TeamID string `json:"team_id"` + AgentKeys []string `json:"agent_keys"` + Sections map[string]interface{} `json:"sections"` +} + +// handleTeamExportPreview returns team export counts without building the archive. +func (h *AgentsHandler) handleTeamExportPreview(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + // Auth already enforced by adminMiddleware on the route. + + teamIDStr := r.PathValue("id") + teamID, err := uuid.Parse(teamIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "team")) + return + } + + teamMeta, err := pg.ExportTeamByID(r.Context(), h.db, teamID) + if err != nil || teamMeta == nil { + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "team", teamIDStr)) + return + } + + tasks, members, links, _ := pg.ExportTeamPreviewCountsByID(r.Context(), h.db, teamID) + + agentMembers, err := pg.GetTeamMemberAgents(r.Context(), h.db, teamID) + if err != nil { + slog.Warn("team.export.preview: get members failed", "team_id", teamID, "error", err) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "team_name": teamMeta.Name, + "team_id": teamIDStr, + "tasks": tasks, + "members": members, + "agent_links": links, + "agent_count": len(agentMembers), + }) +} + +// handleTeamExport builds a team export archive and streams or SSE-wraps it. +func (h *AgentsHandler) handleTeamExport(w http.ResponseWriter, r *http.Request) { + userID := store.UserIDFromContext(r.Context()) + locale := store.LocaleFromContext(r.Context()) + // Auth already enforced by adminMiddleware on the route. + + teamIDStr := r.PathValue("id") + teamID, err := uuid.Parse(teamIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "team")) + return + } + + teamMeta, err := pg.ExportTeamByID(r.Context(), h.db, teamID) + if err != nil || teamMeta == nil { + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "team", teamIDStr)) + return + } + + stream := r.URL.Query().Get("stream") == "true" + fileName := fmt.Sprintf("team-%s-%s.tar.gz", + sanitizeName(teamMeta.Name), + time.Now().UTC().Format("20060102"), + ) + + if stream { + flusher := initSSE(w) + if flusher == nil { + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, "streaming not supported") + return + } + + tmpFile, err := os.CreateTemp("", "goclaw-team-export-*.tar.gz") + if err != nil { + sendSSE(w, flusher, "error", ProgressEvent{Phase: "init", Status: "error", Detail: "failed to create temp file"}) + return + } + tmpPath := tmpFile.Name() + + progressFn := func(ev ProgressEvent) { sendSSE(w, flusher, "progress", ev) } + buildErr := h.writeTeamExportArchive(r.Context(), tmpFile, teamID, teamMeta, progressFn) + tmpFile.Close() + + if buildErr != nil { + slog.Error("team.export.sse", "team_id", teamID, "error", buildErr) + sendSSE(w, flusher, "error", ProgressEvent{Phase: "archive", Status: "error", Detail: buildErr.Error()}) + os.Remove(tmpPath) + return + } + + token := h.generateExportToken(teamID.String(), userID, tmpPath, fileName) + sendSSE(w, flusher, "complete", map[string]string{ + "download_url": "/v1/export/download/" + token, + }) + return + } + + // Direct streaming response + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName)) + if err := h.writeTeamExportArchive(r.Context(), w, teamID, teamMeta, nil); err != nil { + slog.Error("team.export.direct", "team_id", teamID, "error", err) + } +} + +// writeTeamExportArchive builds the team tar.gz archive: team/ metadata + agents/{key}/ per member. +func (h *AgentsHandler) writeTeamExportArchive(ctx context.Context, w io.Writer, teamID uuid.UUID, teamMeta *pg.TeamExport, progressFn func(ProgressEvent)) error { + lw := &limitedWriter{w: w, limit: maxExportSize} + gw := gzip.NewWriter(lw) + tw := tar.NewWriter(gw) + + manifest := &TeamExportManifest{ + Version: 1, + Format: "goclaw-team-export", + ExportedAt: time.Now().UTC().Format(time.RFC3339), + ExportedBy: store.UserIDFromContext(ctx), + TeamName: teamMeta.Name, + TeamID: teamID.String(), + AgentKeys: []string{}, + Sections: make(map[string]interface{}), + } + + // team/team.json + teamJSON, err := jsonIndent(teamMeta) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal team: %w", err) + } + if err := addToTar(tw, "team/team.json", teamJSON); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write team/team.json: %w", err) + } + + // team/members.jsonl + allMembers, err := pg.ExportTeamMembersAll(ctx, h.db, teamID) + if err != nil { + slog.Warn("team.export: members query failed", "team_id", teamID, "error", err) + } + if len(allMembers) > 0 { + data, err := marshalJSONL(allMembers) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal members: %w", err) + } + if err := addToTar(tw, "team/members.jsonl", data); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write team/members.jsonl: %w", err) + } + } + + // team/tasks.jsonl, team/comments.jsonl, team/events.jsonl + tasksExport, err := pg.ExportTeamTasks(ctx, h.db, teamID) + if err != nil { + slog.Warn("team.export: tasks query failed", "team_id", teamID, "error", err) + tasksExport = &pg.TeamTasksExport{} + } + if len(tasksExport.Tasks) > 0 { + data, err := marshalJSONL(tasksExport.Tasks) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal tasks: %w", err) + } + if err := addToTar(tw, "team/tasks.jsonl", data); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write team/tasks.jsonl: %w", err) + } + } + + comments, _ := pg.ExportTeamComments(ctx, h.db, teamID, tasksExport.TaskUIDs) + if len(comments) > 0 { + data, err := marshalJSONL(comments) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal comments: %w", err) + } + if err := addToTar(tw, "team/comments.jsonl", data); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write team/comments.jsonl: %w", err) + } + } + + events, _ := pg.ExportTeamEvents(ctx, h.db, teamID, tasksExport.TaskUIDs) + if len(events) > 0 { + data, err := marshalJSONL(events) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal events: %w", err) + } + if err := addToTar(tw, "team/events.jsonl", data); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write team/events.jsonl: %w", err) + } + } + + // team/links.jsonl + links, _ := pg.ExportTeamLinksForTeam(ctx, h.db, teamID) + if len(links) > 0 { + data, err := marshalJSONL(links) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal links: %w", err) + } + if err := addToTar(tw, "team/links.jsonl", data); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write team/links.jsonl: %w", err) + } + } + + // team/workspace/ + if h.dataDir != "" { + wsPath := filepath.Join(config.ExpandHome(h.dataDir), "teams", teamID.String()) + h.exportTeamWorkspaceFiles(ctx, tw, wsPath, progressFn) //nolint:errcheck + } + + manifest.Sections["team"] = map[string]any{ + "members": len(allMembers), + "tasks": len(tasksExport.Tasks), + "comments": len(comments), + "events": len(events), + "links": len(links), + } + if progressFn != nil { + progressFn(ProgressEvent{Phase: "team", Status: "done", Current: len(tasksExport.Tasks), Total: len(tasksExport.Tasks)}) + } + + // agents/{key}/ — export each member agent's data + agentMembers, err := pg.GetTeamMemberAgents(ctx, h.db, teamID) + if err != nil { + slog.Warn("team.export: get member agents failed", "team_id", teamID, "error", err) + } + + sections := map[string]bool{ + "context_files": true, + "memory": true, + "knowledge_graph": true, + "cron": true, + "user_profiles": true, + "user_overrides": true, + "workspace": true, + } + + for _, member := range agentMembers { + manifest.AgentKeys = append(manifest.AgentKeys, member.AgentKey) + prefix := "agents/" + sanitizeName(member.AgentKey) + "/" + + // agent.json + agentJSON, err := pg.ExportTeamAgentJSON(ctx, h.db, member.ID) + if err != nil { + slog.Warn("team.export: agent json failed", "agent_id", member.ID, "error", err) + continue + } + if err := addToTar(tw, prefix+"agent.json", agentJSON); err != nil { + slog.Warn("team.export: write agent.json failed", "key", member.AgentKey, "error", err) + continue + } + + // Write all agent sections into archive under prefix + agData, _ := h.agents.GetByID(ctx, member.ID) + var counts agentSectionCounts + if agData != nil { + if progressFn != nil { + progressFn(ProgressEvent{Phase: member.AgentKey, Status: "running"}) + } + var secErr error + counts, secErr = h.writeAgentSectionsToTar(ctx, tw, agData, prefix, sections) + if secErr != nil { + slog.Warn("team.export: agent sections failed", "key", member.AgentKey, "error", secErr) + } + } + + if progressFn != nil { + progressFn(ProgressEvent{Phase: member.AgentKey, Status: "done", Detail: counts.summary()}) + } + } + + manifest.Sections["agents"] = map[string]any{"count": len(agentMembers)} + + // manifest.json last + manifestJSON, err := jsonIndent(manifest) + if err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("marshal manifest: %w", err) + } + if err := addToTar(tw, "manifest.json", manifestJSON); err != nil { + tw.Close() + gw.Close() + return fmt.Errorf("write manifest.json: %w", err) + } + + if err := tw.Close(); err != nil { + gw.Close() + return fmt.Errorf("close tar: %w", err) + } + return gw.Close() +} + +// agentSectionCounts tracks how many items were exported per section for progress reporting. +type agentSectionCounts struct { + contextFiles int + memory int + kgEntities int + kgRelations int + cronJobs int + profiles int + overrides int + wsFiles int +} + +func (c agentSectionCounts) summary() string { + var parts []string + if c.contextFiles > 0 { + parts = append(parts, fmt.Sprintf("%d context files", c.contextFiles)) + } + if c.memory > 0 { + parts = append(parts, fmt.Sprintf("%d memory docs", c.memory)) + } + if c.kgEntities > 0 || c.kgRelations > 0 { + parts = append(parts, fmt.Sprintf("%d entities · %d relations", c.kgEntities, c.kgRelations)) + } + if c.cronJobs > 0 { + parts = append(parts, fmt.Sprintf("%d cron jobs", c.cronJobs)) + } + if c.profiles > 0 { + parts = append(parts, fmt.Sprintf("%d user profiles", c.profiles)) + } + if c.overrides > 0 { + parts = append(parts, fmt.Sprintf("%d overrides", c.overrides)) + } + if c.wsFiles > 0 { + parts = append(parts, fmt.Sprintf("%d workspace files", c.wsFiles)) + } + if len(parts) == 0 { + return "config only" + } + return strings.Join(parts, " · ") +} + +// writeAgentSectionsToTar writes agent data sections into an existing tar.Writer with the given prefix. +// This allows team export to embed per-agent data under agents/{key}/ without creating nested archives. +func (h *AgentsHandler) writeAgentSectionsToTar(ctx context.Context, tw *tar.Writer, ag *store.AgentData, prefix string, sections map[string]bool) (agentSectionCounts, error) { + var c agentSectionCounts + + // context_files (agent-level + per-user) + if sections["context_files"] { + files, err := pg.ExportAgentContextFiles(ctx, h.db, ag.ID) + if err != nil { + slog.Warn("team.export.agent.context_files", "agent_id", ag.ID, "error", err) + } + for _, f := range files { + path := prefix + "context_files/" + sanitizeName(f.FileName) + if err := addToTar(tw, path, []byte(f.Content)); err != nil { + return c, fmt.Errorf("write %s: %w", path, err) + } + } + c.contextFiles += len(files) + userFiles, err := pg.ExportUserContextFiles(ctx, h.db, ag.ID) + if err != nil { + slog.Warn("team.export.agent.user_context_files", "agent_id", ag.ID, "error", err) + } + for _, f := range userFiles { + path := prefix + "user_context_files/" + sanitizeName(f.UserID) + "/" + sanitizeName(f.FileName) + if err := addToTar(tw, path, []byte(f.Content)); err != nil { + return c, fmt.Errorf("write %s: %w", path, err) + } + } + c.contextFiles += len(userFiles) + } + + // memory (global + per-user) + if sections["memory"] { + docs, err := pg.ExportMemoryDocuments(ctx, h.db, ag.ID) + if err != nil { + slog.Warn("team.export.agent.memory", "agent_id", ag.ID, "error", err) + } + globalDocs := make([]MemoryExport, 0) + perUser := make(map[string][]MemoryExport) + for _, d := range docs { + me := MemoryExport{Path: d.Path, Content: d.Content, UserID: d.UserID} + if d.UserID == "" { + globalDocs = append(globalDocs, me) + } else { + perUser[d.UserID] = append(perUser[d.UserID], me) + } + } + if len(globalDocs) > 0 { + data, _ := marshalJSONL(globalDocs) + addToTar(tw, prefix+"memory/global.jsonl", data) //nolint:errcheck + } + for uid, udocs := range perUser { + data, _ := marshalJSONL(udocs) + addToTar(tw, prefix+"memory/users/"+sanitizeName(uid)+".jsonl", data) //nolint:errcheck + } + c.memory = len(docs) + } + + // knowledge_graph (entities + relations) + if sections["knowledge_graph"] { + entities, err := pg.ExportKGEntities(ctx, h.db, ag.ID) + if err != nil { + slog.Warn("team.export.agent.kg_entities", "agent_id", ag.ID, "error", err) + } + idToExternal := make(map[string]string, len(entities)) + exportEntities := make([]KGEntityExport, 0, len(entities)) + for _, e := range entities { + idToExternal[e.ID] = e.ExternalID + exportEntities = append(exportEntities, KGEntityExport{ + ExternalID: e.ExternalID, UserID: e.UserID, Name: e.Name, + EntityType: e.EntityType, Description: e.Description, + Properties: e.Properties, Confidence: e.Confidence, + }) + } + if len(exportEntities) > 0 { + data, _ := marshalJSONL(exportEntities) + addToTar(tw, prefix+"knowledge_graph/entities.jsonl", data) //nolint:errcheck + } + c.kgEntities = len(entities) + relations, err := pg.ExportKGRelations(ctx, h.db, ag.ID) + if err != nil { + slog.Warn("team.export.agent.kg_relations", "agent_id", ag.ID, "error", err) + } + exportRelations := make([]KGRelationExport, 0, len(relations)) + for _, rel := range relations { + exportRelations = append(exportRelations, KGRelationExport{ + SourceExternalID: idToExternal[rel.SourceEntityID], + TargetExternalID: idToExternal[rel.TargetEntityID], + UserID: rel.UserID, RelationType: rel.RelationType, + Confidence: rel.Confidence, Properties: rel.Properties, + }) + } + if len(exportRelations) > 0 { + data, _ := marshalJSONL(exportRelations) + addToTar(tw, prefix+"knowledge_graph/relations.jsonl", data) //nolint:errcheck + } + c.kgRelations = len(relations) + } + + // cron + if sections["cron"] { + jobs, err := pg.ExportCronJobs(ctx, h.db, ag.ID) + if err != nil { + slog.Warn("team.export.agent.cron", "agent_id", ag.ID, "error", err) + } + if len(jobs) > 0 { + data, _ := marshalJSONL(jobs) + addToTar(tw, prefix+"cron/jobs.jsonl", data) //nolint:errcheck + } + c.cronJobs = len(jobs) + } + + // user_profiles + if sections["user_profiles"] { + profiles, err := pg.ExportUserProfiles(ctx, h.db, ag.ID) + if err != nil { + slog.Warn("team.export.agent.user_profiles", "agent_id", ag.ID, "error", err) + } + if len(profiles) > 0 { + data, _ := marshalJSONL(profiles) + addToTar(tw, prefix+"user_profiles.jsonl", data) //nolint:errcheck + } + c.profiles = len(profiles) + } + + // user_overrides + if sections["user_overrides"] { + overrides, err := pg.ExportUserOverrides(ctx, h.db, ag.ID) + if err != nil { + slog.Warn("team.export.agent.user_overrides", "agent_id", ag.ID, "error", err) + } + if len(overrides) > 0 { + data, _ := marshalJSONL(overrides) + addToTar(tw, prefix+"user_overrides.jsonl", data) //nolint:errcheck + } + c.overrides = len(overrides) + } + + // workspace + if sections["workspace"] && ag.Workspace != "" { + wsPath := config.ExpandHome(ag.Workspace) + wsCount, _, _ := h.exportWorkspaceFilesWithPrefix(ctx, tw, wsPath, prefix+"workspace/", nil) + c.wsFiles = wsCount + } + + return c, nil +} diff --git a/internal/http/teams_import.go b/internal/http/teams_import.go new file mode 100644 index 00000000..1fb9e530 --- /dev/null +++ b/internal/http/teams_import.go @@ -0,0 +1,426 @@ +package http + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// teamImportArchive holds parsed contents of a team export archive. +type teamImportArchive struct { + manifest *TeamExportManifest + teamMeta *pg.TeamExport + teamMembers []pg.TeamMemberExport + teamTasks []pg.TeamTaskExport + teamComments []pg.TeamTaskCommentExport + teamEvents []pg.TeamTaskEventExport + teamLinks []pg.AgentLinkExport + teamWorkspace map[string][]byte // relative path → content + // agentArcs maps agent_key → importArchive for each member agent in the team archive + agentArcs map[string]*importArchive +} + +// handleTeamImport imports a team archive (POST /v1/teams/import). +func (h *AgentsHandler) handleTeamImport(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + // Auth already enforced by adminMiddleware on the route. + + r.Body = http.MaxBytesReader(w, r.Body, maxImportBodySize) + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "multipart parse: "+err.Error())) + return + } + + f, _, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "missing 'file' field")) + return + } + defer f.Close() + + teamArc, err := readTeamImportArchive(f) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "archive parse: "+err.Error())) + return + } + + stream := r.URL.Query().Get("stream") == "true" + if stream { + flusher := initSSE(w) + if flusher == nil { + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, "streaming not supported") + return + } + progressFn := func(ev ProgressEvent) { sendSSE(w, flusher, "progress", ev) } + summary, importErr := h.doTeamImport(r.Context(), r, teamArc, progressFn) + if importErr != nil { + sendSSE(w, flusher, "error", ProgressEvent{Phase: "import", Status: "error", Detail: importErr.Error()}) + return + } + sendSSE(w, flusher, "complete", summary) + return + } + + summary, err := h.doTeamImport(r.Context(), r, teamArc, nil) + if err != nil { + slog.Error("team.import", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, err.Error())) + return + } + writeJSON(w, http.StatusCreated, summary) +} + +// readTeamImportArchive parses a team tar.gz, separating team/ entries from agents/{key}/ entries. +func readTeamImportArchive(r io.Reader) (*teamImportArchive, error) { + entries, err := readTarGzEntries(r) + if err != nil { + return nil, err + } + + arc := &teamImportArchive{ + agentArcs: make(map[string]*importArchive), + } + + // Parse manifest + if raw, ok := entries["manifest.json"]; ok { + var m TeamExportManifest + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("parse manifest: %w", err) + } + arc.manifest = &m + } + + // Parse team section + if raw, ok := entries["team/team.json"]; ok { + var t pg.TeamExport + if err := json.Unmarshal(raw, &t); err != nil { + return nil, fmt.Errorf("parse team/team.json: %w", err) + } + arc.teamMeta = &t + } + if arc.teamMeta == nil { + return nil, fmt.Errorf("archive missing team/team.json") + } + + if raw, ok := entries["team/members.jsonl"]; ok { + items, err := parseJSONL[pg.TeamMemberExport](raw) + if err != nil { + return nil, fmt.Errorf("parse team/members.jsonl: %w", err) + } + arc.teamMembers = items + } + + if raw, ok := entries["team/tasks.jsonl"]; ok { + items, err := parseJSONL[pg.TeamTaskExport](raw) + if err != nil { + slog.Warn("team.import: parse team/tasks.jsonl", "error", err) + } else { + arc.teamTasks = items + } + } + + if raw, ok := entries["team/comments.jsonl"]; ok { + items, err := parseJSONL[pg.TeamTaskCommentExport](raw) + if err != nil { + slog.Warn("team.import: parse team/comments.jsonl", "error", err) + } else { + arc.teamComments = items + } + } + + if raw, ok := entries["team/events.jsonl"]; ok { + items, err := parseJSONL[pg.TeamTaskEventExport](raw) + if err != nil { + slog.Warn("team.import: parse team/events.jsonl", "error", err) + } else { + arc.teamEvents = items + } + } + + if raw, ok := entries["team/links.jsonl"]; ok { + items, err := parseJSONL[pg.AgentLinkExport](raw) + if err != nil { + slog.Warn("team.import: parse team/links.jsonl", "error", err) + } else { + arc.teamLinks = items + } + } + + // team/workspace/* files + arc.teamWorkspace = make(map[string][]byte) + for name, data := range entries { + if !strings.HasPrefix(name, "team/workspace/") { + continue + } + rel := strings.TrimPrefix(name, "team/workspace/") + if rel != "" { + arc.teamWorkspace[rel] = data + } + } + + // Group agents/{key}/* entries by agent key + agentEntries := make(map[string]map[string][]byte) + for name, data := range entries { + if !strings.HasPrefix(name, "agents/") { + continue + } + rest := strings.TrimPrefix(name, "agents/") + slashIdx := strings.Index(rest, "/") + if slashIdx < 0 { + continue + } + agentKey := rest[:slashIdx] + relPath := rest[slashIdx+1:] + if agentKey == "" || relPath == "" { + continue + } + if agentEntries[agentKey] == nil { + agentEntries[agentKey] = make(map[string][]byte) + } + agentEntries[agentKey][relPath] = data + } + + // Build per-agent importArchive from grouped entries + for agentKey, agentEnts := range agentEntries { + agArc, err := buildAgentArcFromEntries(agentEnts) + if err != nil { + slog.Warn("team.import: failed to parse agent archive", "key", agentKey, "error", err) + continue + } + arc.agentArcs[agentKey] = agArc + } + + // Also build importArchive stubs for members listed in team/members.jsonl + // but not present as agents/ entries (graceful degradation) + for _, m := range arc.teamMembers { + if _, ok := arc.agentArcs[m.AgentKey]; !ok { + arc.agentArcs[m.AgentKey] = &importArchive{ + memoryUsers: make(map[string][]MemoryExport), + workspaceFiles: make(map[string][]byte), + teamWorkspace: make(map[string][]byte), + } + } + } + + return arc, nil +} + +// buildAgentArcFromEntries constructs an importArchive from a flat entry map (relative paths within agents/{key}/). +func buildAgentArcFromEntries(entries map[string][]byte) (*importArchive, error) { + arc := &importArchive{ + memoryUsers: make(map[string][]MemoryExport), + workspaceFiles: make(map[string][]byte), + teamWorkspace: make(map[string][]byte), + } + + if raw, ok := entries["agent.json"]; ok { + var cfg map[string]json.RawMessage + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("parse agent.json: %w", err) + } + arc.agentConfig = cfg + } + + for name, data := range entries { + switch { + case strings.HasPrefix(name, "context_files/"): + fileName := strings.TrimPrefix(name, "context_files/") + if fileName != "" { + arc.contextFiles = append(arc.contextFiles, importContextFile{ + fileName: fileName, + content: string(data), + }) + } + case strings.HasPrefix(name, "user_context_files/"): + rest := strings.TrimPrefix(name, "user_context_files/") + if idx := strings.Index(rest, "/"); idx > 0 { + userID := rest[:idx] + fileName := rest[idx+1:] + if fileName != "" { + arc.userContextFiles = append(arc.userContextFiles, importUserContextFile{ + userID: userID, + fileName: fileName, + content: string(data), + }) + } + } + case name == "memory/global.jsonl": + items, err := parseJSONL[MemoryExport](data) + if err == nil { + arc.memoryGlobal = items + } + case strings.HasPrefix(name, "memory/users/") && strings.HasSuffix(name, ".jsonl"): + items, err := parseJSONL[MemoryExport](data) + if err == nil { + uid := strings.TrimSuffix(strings.TrimPrefix(name, "memory/users/"), ".jsonl") + arc.memoryUsers[uid] = items + } + case name == "knowledge_graph/entities.jsonl": + items, err := parseJSONL[KGEntityExport](data) + if err == nil { + arc.kgEntities = items + } + case name == "knowledge_graph/relations.jsonl": + items, err := parseJSONL[KGRelationExport](data) + if err == nil { + arc.kgRelations = items + } + case name == "cron/jobs.jsonl": + items, err := parseJSONL[pg.CronJobExport](data) + if err == nil { + arc.cronJobs = items + } + case name == "user_profiles.jsonl": + items, err := parseJSONL[pg.UserProfileExport](data) + if err == nil { + arc.userProfiles = items + } + case name == "user_overrides.jsonl": + items, err := parseJSONL[pg.UserOverrideExport](data) + if err == nil { + arc.userOverrides = items + } + case strings.HasPrefix(name, "workspace/"): + rel := strings.TrimPrefix(name, "workspace/") + if rel != "" { + arc.workspaceFiles[rel] = data + } + } + } + + return arc, nil +} + +// TeamImportSummary is returned after a successful team import. +type TeamImportSummary struct { + TeamName string `json:"team_name"` + AgentsAdded int `json:"agents_added"` + AgentKeys []string `json:"agent_keys"` +} + +// doTeamImport imports each agent from the team archive then wires up the team structure. +func (h *AgentsHandler) doTeamImport(ctx context.Context, r *http.Request, teamArc *teamImportArchive, progressFn func(ProgressEvent)) (*TeamImportSummary, error) { + summary := &TeamImportSummary{ + TeamName: teamArc.teamMeta.Name, + AgentKeys: []string{}, + } + + // Import order: use AgentKeys from manifest if present, else iterate agentArcs + var agentKeys []string + if teamArc.manifest != nil { + agentKeys = teamArc.manifest.AgentKeys + } + if len(agentKeys) == 0 { + for k := range teamArc.agentArcs { + agentKeys = append(agentKeys, k) + } + } + + // First pass: import/find each agent + importedAgents := make(map[string]string) // agent_key → imported agent_key (may be deduplicated) + + for _, agentKey := range agentKeys { + agArc, ok := teamArc.agentArcs[agentKey] + if !ok { + continue + } + // Ensure agentConfig is populated (may be empty for member-only stubs) + if agArc.agentConfig == nil { + agArc.agentConfig = make(map[string]json.RawMessage) + } + // Inject agent_key from manifest if not in agentConfig + if agArc.agentConfig["agent_key"] == nil { + raw, _ := json.Marshal(agentKey) + agArc.agentConfig["agent_key"] = raw + } + + dedupedKey := h.dedupAgentKey(ctx, agentKey) + tenantID := store.TenantIDFromContext(ctx) + userID := store.UserIDFromContext(ctx) + ag := h.buildAgentFromArchive(agArc.agentConfig, dedupedKey, "", tenantID, userID) + + if progressFn != nil { + progressFn(ProgressEvent{Phase: "agent", Status: "running", Detail: dedupedKey}) + } + + if err := h.agents.Create(ctx, ag); err != nil { + slog.Warn("team.import: create agent failed", "key", dedupedKey, "error", err) + continue + } + + sections := map[string]bool{ + "context_files": true, + "memory": true, + "knowledge_graph": true, + "cron": true, + "user_profiles": true, + "user_overrides": true, + "workspace": true, + } + if _, err := h.doMergeImport(ctx, ag, agArc, sections, progressFn); err != nil { + slog.Warn("team.import: merge agent data failed", "key", dedupedKey, "error", err) + } + + importedAgents[agentKey] = dedupedKey + summary.AgentsAdded++ + summary.AgentKeys = append(summary.AgentKeys, dedupedKey) + + if progressFn != nil { + progressFn(ProgressEvent{Phase: "agent", Status: "done", Detail: dedupedKey}) + } + } + + if len(importedAgents) == 0 { + return nil, fmt.Errorf("no agents could be imported from archive") + } + + // Second pass: import the team itself, using the first imported agent as lead. + // Build a synthetic importArchive with team data for importTeamSection. + leadKey := agentKeys[0] + leadImportedKey, ok := importedAgents[leadKey] + if !ok { + // Fallback: pick any imported agent + for _, k := range importedAgents { + leadImportedKey = k + break + } + } + + leadAgent, err := h.agents.GetByKey(ctx, leadImportedKey) + if err != nil || leadAgent == nil { + slog.Warn("team.import: lead agent not found after import", "key", leadImportedKey) + return summary, nil + } + + // Build a synthetic importArchive containing team data + syntheticArc := &importArchive{ + memoryUsers: make(map[string][]MemoryExport), + workspaceFiles: make(map[string][]byte), + teamWorkspace: teamArc.teamWorkspace, + teamMeta: teamArc.teamMeta, + teamMembers: teamArc.teamMembers, + teamTasks: teamArc.teamTasks, + teamComments: teamArc.teamComments, + teamEvents: teamArc.teamEvents, + teamLinks: teamArc.teamLinks, + } + + if err := h.importTeamSection(ctx, leadAgent, syntheticArc, progressFn); err != nil { + slog.Warn("team.import: team section failed", "error", err) + } + + if progressFn != nil { + progressFn(ProgressEvent{Phase: "team", Status: "done", Detail: teamArc.teamMeta.Name}) + } + + return summary, nil +} diff --git a/internal/store/pg/agents_export_queries.go b/internal/store/pg/agents_export_queries.go new file mode 100644 index 00000000..d40dc1f6 --- /dev/null +++ b/internal/store/pg/agents_export_queries.go @@ -0,0 +1,496 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "log/slog" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// Export types — used exclusively by the agent export pipeline. + +type AgentContextFileExport struct { + FileName string + Content string +} + +type UserContextFileExport struct { + UserID string + FileName string + Content string +} + +type MemoryDocExport struct { + Path string + Content string + UserID string +} + +// Phase 2 export types. + +type SkillGrantExport struct { + SkillID string `json:"skill_id"` + PinnedVersion int `json:"pinned_version"` + GrantedBy string `json:"granted_by"` +} + +type MCPGrantExport struct { + ServerID string `json:"server_id"` + Enabled bool `json:"enabled"` + ToolAllow json.RawMessage `json:"tool_allow,omitempty"` + ToolDeny json.RawMessage `json:"tool_deny,omitempty"` + ConfigOverrides json.RawMessage `json:"config_overrides,omitempty"` + GrantedBy string `json:"granted_by"` +} + +type CronJobExport struct { + Name string `json:"name"` + ScheduleKind string `json:"schedule_kind"` + CronExpression *string `json:"cron_expression,omitempty"` + IntervalMS *int64 `json:"interval_ms,omitempty"` + RunAt *string `json:"run_at,omitempty"` + Timezone *string `json:"timezone,omitempty"` + Payload json.RawMessage `json:"payload"` + DeleteAfterRun bool `json:"delete_after_run"` +} + +type ConfigPermissionExport struct { + Scope string `json:"scope"` + ConfigType string `json:"config_type"` + UserID string `json:"user_id"` + Permission string `json:"permission"` + Metadata json.RawMessage `json:"metadata,omitempty"` + GrantedBy *string `json:"granted_by,omitempty"` +} + +type UserProfileExport struct { + UserID string `json:"user_id"` + Workspace *string `json:"workspace,omitempty"` +} + +type UserOverrideExport struct { + UserID string `json:"user_id"` + Provider *string `json:"provider,omitempty"` + Model *string `json:"model,omitempty"` + Settings json.RawMessage `json:"settings,omitempty"` +} + +type ExportPreview struct { + ContextFiles int `json:"context_files"` + UserContextFiles int `json:"user_context_files_users"` + MemoryGlobal int `json:"memory_global"` + MemoryPerUser int `json:"memory_per_user"` + KGEntities int `json:"kg_entities"` + KGRelations int `json:"kg_relations"` + CronJobs int `json:"cron_jobs"` + UserProfiles int `json:"user_profiles"` + UserOverrides int `json:"user_overrides"` + // Team section + TeamTasks int `json:"team_tasks"` + TeamMembers int `json:"team_members"` + AgentLinks int `json:"agent_links"` +} + +const exportBatchSize = 1000 + +// ExportAgentContextFiles returns all agent-level context files for the given agent. +func ExportAgentContextFiles(ctx context.Context, db *sql.DB, agentID uuid.UUID) ([]AgentContextFileExport, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT file_name, content FROM agent_context_files WHERE agent_id = $1"+tc, + append([]any{agentID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []AgentContextFileExport + for rows.Next() { + var f AgentContextFileExport + if err := rows.Scan(&f.FileName, &f.Content); err != nil { + slog.Warn("export.scan", "error", err) + continue + } + result = append(result, f) + } + return result, rows.Err() +} + +// ExportUserContextFiles returns all per-user context files for the given agent (all users). +func ExportUserContextFiles(ctx context.Context, db *sql.DB, agentID uuid.UUID) ([]UserContextFileExport, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT user_id, file_name, content FROM user_context_files WHERE agent_id = $1"+tc, + append([]any{agentID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []UserContextFileExport + for rows.Next() { + var f UserContextFileExport + if err := rows.Scan(&f.UserID, &f.FileName, &f.Content); err != nil { + slog.Warn("export.scan", "error", err) + continue + } + result = append(result, f) + } + return result, rows.Err() +} + +// ExportMemoryDocuments returns all memory documents for the given agent across all users, +// using cursor-based pagination to handle large datasets. +func ExportMemoryDocuments(ctx context.Context, db *sql.DB, agentID uuid.UUID) ([]MemoryDocExport, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + + baseArgs := append([]any{agentID}, tcArgs...) + cursorParam := len(baseArgs) + 1 + limitParam := cursorParam + 1 + + var result []MemoryDocExport + cursor := uuid.Nil + + for { + args := append(append([]any{}, baseArgs...), cursor, exportBatchSize) + rows, err := db.QueryContext(ctx, + "SELECT id, path, content, COALESCE(user_id, '') FROM memory_documents"+ + " WHERE agent_id = $1"+tc+ + " AND id > $"+itoa(cursorParam)+ + " ORDER BY id LIMIT $"+itoa(limitParam), + args..., + ) + if err != nil { + return nil, err + } + + count := 0 + for rows.Next() { + var id uuid.UUID + var d MemoryDocExport + if err := rows.Scan(&id, &d.Path, &d.Content, &d.UserID); err != nil { + continue + } + result = append(result, d) + cursor = id + count++ + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + if count < exportBatchSize { + break + } + } + return result, nil +} + +// ExportKGEntities returns all KG entities for the given agent across all user scopes, +// using cursor-based pagination. +func ExportKGEntities(ctx context.Context, db *sql.DB, agentID uuid.UUID) ([]store.Entity, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + + baseArgs := append([]any{agentID}, tcArgs...) + cursorParam := len(baseArgs) + 1 + limitParam := cursorParam + 1 + + var result []store.Entity + cursor := uuid.Nil + + for { + args := append(append([]any{}, baseArgs...), cursor, exportBatchSize) + rows, err := db.QueryContext(ctx, + "SELECT id, agent_id, user_id, external_id, name, entity_type, description,"+ + " properties, source_id, confidence, created_at, updated_at"+ + " FROM kg_entities WHERE agent_id = $1"+tc+ + " AND id > $"+itoa(cursorParam)+ + " ORDER BY id LIMIT $"+itoa(limitParam), + args..., + ) + if err != nil { + return nil, err + } + + batch, scanErr := scanEntities(rows) + rows.Close() + if scanErr != nil { + return nil, scanErr + } + result = append(result, batch...) + if len(batch) < exportBatchSize { + break + } + // Advance cursor: last entity ID + lastID := mustParseUUID(batch[len(batch)-1].ID) + cursor = lastID + } + return result, nil +} + +// ExportKGRelations returns all KG relations for the given agent across all user scopes, +// using cursor-based pagination. +func ExportKGRelations(ctx context.Context, db *sql.DB, agentID uuid.UUID) ([]store.Relation, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + + baseArgs := append([]any{agentID}, tcArgs...) + cursorParam := len(baseArgs) + 1 + limitParam := cursorParam + 1 + + var result []store.Relation + cursor := uuid.Nil + + for { + args := append(append([]any{}, baseArgs...), cursor, exportBatchSize) + rows, err := db.QueryContext(ctx, + "SELECT id, agent_id, user_id, source_entity_id, relation_type, target_entity_id,"+ + " confidence, properties, created_at"+ + " FROM kg_relations WHERE agent_id = $1"+tc+ + " AND id > $"+itoa(cursorParam)+ + " ORDER BY id LIMIT $"+itoa(limitParam), + args..., + ) + if err != nil { + return nil, err + } + + batch, scanErr := scanRelations(rows) + rows.Close() + if scanErr != nil { + return nil, scanErr + } + result = append(result, batch...) + if len(batch) < exportBatchSize { + break + } + lastID := mustParseUUID(batch[len(batch)-1].ID) + cursor = lastID + } + return result, nil +} + +// ExportPreviewCounts returns aggregate counts for all exportable sections of an agent. +func ExportPreviewCounts(ctx context.Context, db *sql.DB, agentID uuid.UUID) (*ExportPreview, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + args := append([]any{agentID}, tcArgs...) + + var p ExportPreview + err = db.QueryRowContext(ctx, ` + SELECT + (SELECT COUNT(*) FROM agent_context_files WHERE agent_id = $1`+tc+`) AS context_files, + (SELECT COUNT(DISTINCT user_id) FROM user_context_files WHERE agent_id = $1`+tc+`) AS user_context_files_users, + (SELECT COUNT(*) FROM memory_documents WHERE agent_id = $1 AND user_id IS NULL`+tc+`) AS memory_global, + (SELECT COUNT(*) FROM memory_documents WHERE agent_id = $1 AND user_id IS NOT NULL`+tc+`) AS memory_per_user, + (SELECT COUNT(*) FROM kg_entities WHERE agent_id = $1`+tc+`) AS kg_entities, + (SELECT COUNT(*) FROM kg_relations WHERE agent_id = $1`+tc+`) AS kg_relations, + (SELECT COUNT(*) FROM cron_jobs WHERE agent_id = $1`+tc+`) AS cron_jobs, + (SELECT COUNT(*) FROM user_agent_profiles WHERE agent_id = $1`+tc+`) AS user_profiles, + (SELECT COUNT(*) FROM user_agent_overrides WHERE agent_id = $1`+tc+`) AS user_overrides + `, args...).Scan( + &p.ContextFiles, &p.UserContextFiles, + &p.MemoryGlobal, &p.MemoryPerUser, + &p.KGEntities, &p.KGRelations, + &p.CronJobs, + &p.UserProfiles, &p.UserOverrides, + ) + if err != nil { + return nil, err + } + + // Team counts (separate query — agent may not be a lead) + p.TeamTasks, p.TeamMembers, p.AgentLinks, _ = ExportTeamPreviewCounts(ctx, db, agentID) + + return &p, nil +} + +// ExportSkillGrants returns all skill grants for the given agent. +func ExportSkillGrants(ctx context.Context, db *sql.DB, agentID uuid.UUID) ([]SkillGrantExport, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT skill_id, pinned_version, granted_by FROM skill_agent_grants WHERE agent_id = $1"+tc, + append([]any{agentID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []SkillGrantExport + for rows.Next() { + var g SkillGrantExport + if err := rows.Scan(&g.SkillID, &g.PinnedVersion, &g.GrantedBy); err != nil { + slog.Warn("export.scan", "error", err) + continue + } + result = append(result, g) + } + return result, rows.Err() +} + +// ExportMCPGrants returns all MCP server grants for the given agent. +func ExportMCPGrants(ctx context.Context, db *sql.DB, agentID uuid.UUID) ([]MCPGrantExport, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT server_id, enabled, tool_allow, tool_deny, config_overrides, granted_by"+ + " FROM mcp_agent_grants WHERE agent_id = $1"+tc, + append([]any{agentID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []MCPGrantExport + for rows.Next() { + var g MCPGrantExport + if err := rows.Scan(&g.ServerID, &g.Enabled, &g.ToolAllow, &g.ToolDeny, &g.ConfigOverrides, &g.GrantedBy); err != nil { + slog.Warn("export.scan", "error", err) + continue + } + result = append(result, g) + } + return result, rows.Err() +} + +// ExportCronJobs returns all cron jobs for the given agent. +func ExportCronJobs(ctx context.Context, db *sql.DB, agentID uuid.UUID) ([]CronJobExport, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT name, schedule_kind, cron_expression, interval_ms,"+ + " to_char(run_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'), timezone, payload, delete_after_run"+ + " FROM cron_jobs WHERE agent_id = $1"+tc, + append([]any{agentID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []CronJobExport + for rows.Next() { + var j CronJobExport + if err := rows.Scan(&j.Name, &j.ScheduleKind, &j.CronExpression, &j.IntervalMS, + &j.RunAt, &j.Timezone, &j.Payload, &j.DeleteAfterRun); err != nil { + slog.Warn("export.scan", "error", err) + continue + } + result = append(result, j) + } + return result, rows.Err() +} + +// ExportConfigPermissions returns all agent config permissions for the given agent. +func ExportConfigPermissions(ctx context.Context, db *sql.DB, agentID uuid.UUID) ([]ConfigPermissionExport, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT scope, config_type, user_id, permission, metadata, granted_by"+ + " FROM agent_config_permissions WHERE agent_id = $1"+tc, + append([]any{agentID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []ConfigPermissionExport + for rows.Next() { + var p ConfigPermissionExport + if err := rows.Scan(&p.Scope, &p.ConfigType, &p.UserID, &p.Permission, &p.Metadata, &p.GrantedBy); err != nil { + slog.Warn("export.scan", "error", err) + continue + } + result = append(result, p) + } + return result, rows.Err() +} + +// ExportUserProfiles returns all user profiles for the given agent. +func ExportUserProfiles(ctx context.Context, db *sql.DB, agentID uuid.UUID) ([]UserProfileExport, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT user_id, workspace FROM user_agent_profiles WHERE agent_id = $1"+tc, + append([]any{agentID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []UserProfileExport + for rows.Next() { + var p UserProfileExport + if err := rows.Scan(&p.UserID, &p.Workspace); err != nil { + slog.Warn("export.scan", "error", err) + continue + } + result = append(result, p) + } + return result, rows.Err() +} + +// ExportUserOverrides returns all user model overrides for the given agent. +func ExportUserOverrides(ctx context.Context, db *sql.DB, agentID uuid.UUID) ([]UserOverrideExport, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT user_id, provider, model, settings"+ + " FROM user_agent_overrides WHERE agent_id = $1"+tc, + append([]any{agentID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []UserOverrideExport + for rows.Next() { + var o UserOverrideExport + if err := rows.Scan(&o.UserID, &o.Provider, &o.Model, &o.Settings); err != nil { + slog.Warn("export.scan", "error", err) + continue + } + result = append(result, o) + } + return result, rows.Err() +} + diff --git a/internal/store/pg/agents_export_team_queries.go b/internal/store/pg/agents_export_team_queries.go new file mode 100644 index 00000000..c7ce5107 --- /dev/null +++ b/internal/store/pg/agents_export_team_queries.go @@ -0,0 +1,451 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "log/slog" + + "github.com/google/uuid" +) + +// Team export types — used exclusively by the agent export pipeline. + +// TeamExport holds portable team metadata. +type TeamExport struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + Settings json.RawMessage `json:"settings,omitempty"` +} + +// TeamMemberExport references a team member by agent_key (portable cross-system). +type TeamMemberExport struct { + AgentKey string `json:"agent_key"` + Role string `json:"role"` +} + +// TeamTaskExport is a portable team task (no internal UUIDs). +// Note: blocked_by (UUID[]) is intentionally omitted — task UUIDs change on import, +// so the dependency graph would be invalid. Tasks are imported with cleared blocked_by. +type TeamTaskExport struct { + Subject string `json:"subject"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + Priority int `json:"priority"` + Result *string `json:"result,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + TaskType *string `json:"task_type,omitempty"` + TaskNumber *int `json:"task_number,omitempty"` + Identifier *string `json:"identifier,omitempty"` + OwnerAgentKey *string `json:"owner_agent_key,omitempty"` + CreatedByKey *string `json:"created_by_key,omitempty"` + AssigneeUserID *string `json:"assignee_user_id,omitempty"` + ParentIdx *int `json:"parent_idx,omitempty"` // index into tasks array + ProgressPercent *int `json:"progress_percent,omitempty"` + ProgressStep *string `json:"progress_step,omitempty"` +} + +// TeamTaskCommentExport is a portable task comment referencing task by index. +type TeamTaskCommentExport struct { + TaskIdx int `json:"task_idx"` + AgentKey *string `json:"agent_key,omitempty"` + UserID *string `json:"user_id,omitempty"` + Content string `json:"content"` + CommentType string `json:"comment_type"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +// TeamTaskEventExport is a portable task event referencing task by index. +type TeamTaskEventExport struct { + TaskIdx int `json:"task_idx"` + EventType string `json:"event_type"` + ActorType string `json:"actor_type"` + ActorID string `json:"actor_id"` + Data json.RawMessage `json:"data,omitempty"` +} + +// AgentLinkExport represents an inter-agent link using agent_key references. +type AgentLinkExport struct { + SourceAgentKey string `json:"source_agent_key"` + TargetAgentKey string `json:"target_agent_key"` + Direction string `json:"direction"` + Description string `json:"description,omitempty"` +} + +// TeamTasksExport bundles tasks with their UUIDs for downstream comment/event mapping. +type TeamTasksExport struct { + Tasks []TeamTaskExport + TaskUIDs []uuid.UUID // parallel to Tasks — used to map comments/events by index +} + +// ExportTeamByLead finds the team led by agentID and returns metadata + members. +// Returns (nil, nil, nil) if the agent is not a team lead. +func ExportTeamByLead(ctx context.Context, db *sql.DB, agentID uuid.UUID) (*TeamExport, uuid.UUID, []TeamMemberExport, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, uuid.Nil, nil, err + } + args := append([]any{agentID}, tcArgs...) + + var teamID uuid.UUID + var t TeamExport + err = db.QueryRowContext(ctx, + "SELECT id, name, COALESCE(description,''), status, COALESCE(settings,'{}')"+ + " FROM agent_teams WHERE lead_agent_id = $1"+tc, + args..., + ).Scan(&teamID, &t.Name, &t.Description, &t.Status, &t.Settings) + if err == sql.ErrNoRows { + return nil, uuid.Nil, nil, nil + } + if err != nil { + return nil, uuid.Nil, nil, err + } + + members, err := exportTeamMembers(ctx, db, teamID, agentID) + if err != nil { + return nil, uuid.Nil, nil, err + } + return &t, teamID, members, nil +} + +// exportTeamMembers returns members (excluding lead) with agent_key resolved. +func exportTeamMembers(ctx context.Context, db *sql.DB, teamID, leadAgentID uuid.UUID) ([]TeamMemberExport, error) { + tc, tcArgs, _, err := scopeClauseAlias(ctx, 3, "m") + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT a.agent_key, m.role"+ + " FROM agent_team_members m"+ + " JOIN agents a ON a.id = m.agent_id"+ + " WHERE m.team_id = $1 AND m.agent_id != $2"+tc, + append([]any{teamID, leadAgentID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []TeamMemberExport + for rows.Next() { + var m TeamMemberExport + if err := rows.Scan(&m.AgentKey, &m.Role); err != nil { + slog.Warn("export.team.member.scan", "error", err) + continue + } + out = append(out, m) + } + return out, rows.Err() +} + +// ExportTeamTasks returns all tasks for a team, resolving agent keys for owner/creator. +func ExportTeamTasks(ctx context.Context, db *sql.DB, teamID uuid.UUID) (*TeamTasksExport, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT t.id, t.subject, COALESCE(t.description,''), t.status, t.priority,"+ + " t.result, t.metadata, t.task_type, t.task_number, t.identifier,"+ + " oa.agent_key, ca.agent_key, t.assignee_user_id, t.parent_id,"+ + " t.progress_percent, t.progress_step"+ + " FROM team_tasks t"+ + " LEFT JOIN agents oa ON oa.id = t.owner_agent_id"+ + " LEFT JOIN agents ca ON ca.id = t.created_by_agent_id"+ + " WHERE t.team_id = $1"+tc+ + " ORDER BY t.created_at", + append([]any{teamID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out TeamTasksExport + idxByUID := make(map[uuid.UUID]int) + + for rows.Next() { + var ( + id uuid.UUID + taskResult sql.NullString + metadata []byte + taskType sql.NullString + taskNumber sql.NullInt64 + identifier sql.NullString + ownerAgentKey sql.NullString + createdByKey sql.NullString + assigneeUserID sql.NullString + parentID *uuid.UUID + progressPct sql.NullInt64 + progressStep sql.NullString + ) + var te TeamTaskExport + if err := rows.Scan( + &id, &te.Subject, &te.Description, &te.Status, &te.Priority, + &taskResult, &metadata, &taskType, &taskNumber, &identifier, + &ownerAgentKey, &createdByKey, &assigneeUserID, &parentID, + &progressPct, &progressStep, + ); err != nil { + slog.Warn("export.team.task.scan", "error", err) + continue + } + + if taskResult.Valid { + te.Result = &taskResult.String + } + if len(metadata) > 0 { + te.Metadata = json.RawMessage(metadata) + } + if taskType.Valid { + te.TaskType = &taskType.String + } + if taskNumber.Valid { + n := int(taskNumber.Int64) + te.TaskNumber = &n + } + if identifier.Valid { + te.Identifier = &identifier.String + } + if ownerAgentKey.Valid { + te.OwnerAgentKey = &ownerAgentKey.String + } + if createdByKey.Valid { + te.CreatedByKey = &createdByKey.String + } + if assigneeUserID.Valid { + te.AssigneeUserID = &assigneeUserID.String + } + if progressPct.Valid { + n := int(progressPct.Int64) + te.ProgressPercent = &n + } + if progressStep.Valid { + te.ProgressStep = &progressStep.String + } + + idx := len(out.Tasks) + idxByUID[id] = idx + out.Tasks = append(out.Tasks, te) + out.TaskUIDs = append(out.TaskUIDs, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + + // Second pass: resolve parent_id → parent_idx + if err := resolveTaskParentIdx(ctx, db, teamID, tc, tcArgs, &out, idxByUID); err != nil { + slog.Warn("export.team.tasks.parent_resolve", "error", err) + } + return &out, nil +} + +// resolveTaskParentIdx fills ParentIdx for tasks that have a parent_id. +func resolveTaskParentIdx(ctx context.Context, db *sql.DB, teamID uuid.UUID, tc string, tcArgs []any, out *TeamTasksExport, idxByUID map[uuid.UUID]int) error { + rows, err := db.QueryContext(ctx, + "SELECT id, parent_id FROM team_tasks WHERE team_id = $1 AND parent_id IS NOT NULL"+tc, + append([]any{teamID}, tcArgs...)..., + ) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var id, parentID uuid.UUID + if err := rows.Scan(&id, &parentID); err != nil { + continue + } + taskIdx, ok := idxByUID[id] + if !ok { + continue + } + if parentIdx, ok := idxByUID[parentID]; ok { + out.Tasks[taskIdx].ParentIdx = &parentIdx + } + } + return rows.Err() +} + +// ExportTeamComments returns all comments for a team's tasks, using task index references. +func ExportTeamComments(ctx context.Context, db *sql.DB, teamID uuid.UUID, taskUIDs []uuid.UUID) ([]TeamTaskCommentExport, error) { + if len(taskUIDs) == 0 { + return nil, nil + } + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + idxByUID := make(map[uuid.UUID]int, len(taskUIDs)) + for i, uid := range taskUIDs { + idxByUID[uid] = i + } + + rows, err := db.QueryContext(ctx, + "SELECT c.task_id, a.agent_key, c.user_id, c.content,"+ + " COALESCE(c.comment_type,'note'), c.metadata"+ + " FROM team_task_comments c"+ + " LEFT JOIN agents a ON a.id = c.agent_id"+ + " WHERE c.task_id IN (SELECT id FROM team_tasks WHERE team_id = $1"+tc+")"+ + " ORDER BY c.created_at", + append([]any{teamID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []TeamTaskCommentExport + for rows.Next() { + var ( + taskID uuid.UUID + agentKey sql.NullString + userID sql.NullString + metadata []byte + ) + var ce TeamTaskCommentExport + if err := rows.Scan(&taskID, &agentKey, &userID, &ce.Content, &ce.CommentType, &metadata); err != nil { + slog.Warn("export.team.comment.scan", "error", err) + continue + } + idx, ok := idxByUID[taskID] + if !ok { + continue + } + ce.TaskIdx = idx + if agentKey.Valid { + ce.AgentKey = &agentKey.String + } + if userID.Valid && userID.String != "" { + ce.UserID = &userID.String + } + if len(metadata) > 0 { + ce.Metadata = json.RawMessage(metadata) + } + out = append(out, ce) + } + return out, rows.Err() +} + +// ExportTeamEvents returns all events for a team's tasks, using task index references. +func ExportTeamEvents(ctx context.Context, db *sql.DB, teamID uuid.UUID, taskUIDs []uuid.UUID) ([]TeamTaskEventExport, error) { + if len(taskUIDs) == 0 { + return nil, nil + } + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + idxByUID := make(map[uuid.UUID]int, len(taskUIDs)) + for i, uid := range taskUIDs { + idxByUID[uid] = i + } + + rows, err := db.QueryContext(ctx, + "SELECT task_id, event_type, actor_type, actor_id, data"+ + " FROM team_task_events"+ + " WHERE task_id IN (SELECT id FROM team_tasks WHERE team_id = $1"+tc+")"+ + " ORDER BY created_at", + append([]any{teamID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []TeamTaskEventExport + for rows.Next() { + var ( + taskID uuid.UUID + data []byte + ) + var ev TeamTaskEventExport + if err := rows.Scan(&taskID, &ev.EventType, &ev.ActorType, &ev.ActorID, &data); err != nil { + slog.Warn("export.team.event.scan", "error", err) + continue + } + idx, ok := idxByUID[taskID] + if !ok { + continue + } + ev.TaskIdx = idx + if len(data) > 0 { + ev.Data = json.RawMessage(data) + } + out = append(out, ev) + } + return out, rows.Err() +} + +// ExportAgentLinks returns all agent links where agentID is source or target. +func ExportAgentLinks(ctx context.Context, db *sql.DB, agentID uuid.UUID) ([]AgentLinkExport, error) { + tc, tcArgs, _, err := scopeClauseAlias(ctx, 2, "l") + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT sa.agent_key, ta.agent_key, l.direction, COALESCE(l.description,'')"+ + " FROM agent_links l"+ + " JOIN agents sa ON sa.id = l.source_agent_id"+ + " JOIN agents ta ON ta.id = l.target_agent_id"+ + " WHERE (l.source_agent_id = $1 OR l.target_agent_id = $1)"+tc, + append([]any{agentID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AgentLinkExport + for rows.Next() { + var l AgentLinkExport + if err := rows.Scan(&l.SourceAgentKey, &l.TargetAgentKey, &l.Direction, &l.Description); err != nil { + slog.Warn("export.agent_link.scan", "error", err) + continue + } + out = append(out, l) + } + return out, rows.Err() +} + +// ExportTeamPreviewCounts returns team-related counts for the preview endpoint. +// Returns zeros if the agent is not a team lead. +func ExportTeamPreviewCounts(ctx context.Context, db *sql.DB, agentID uuid.UUID) (teamTasks, teamMembers, agentLinks int, err error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return + } + args := append([]any{agentID}, tcArgs...) + + var teamID uuid.UUID + err = db.QueryRowContext(ctx, + "SELECT id FROM agent_teams WHERE lead_agent_id = $1"+tc, args..., + ).Scan(&teamID) + if err == sql.ErrNoRows { + err = nil + // Still count agent links even if no team + _ = db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM agent_links WHERE (source_agent_id = $1 OR target_agent_id = $1)"+tc, + args..., + ).Scan(&agentLinks) + return + } + if err != nil { + return + } + + tc2, tcArgs2, _, _ := scopeClause(ctx, 2) + _ = db.QueryRowContext(ctx, + "SELECT"+ + " (SELECT COUNT(*) FROM team_tasks WHERE team_id = $1"+tc2+"),"+ + " (SELECT COUNT(*) FROM agent_team_members WHERE team_id = $1"+tc2+")", + append([]any{teamID}, tcArgs2...)..., + ).Scan(&teamTasks, &teamMembers) + + _ = db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM agent_links WHERE (source_agent_id = $1 OR target_agent_id = $1)"+tc2, + append([]any{agentID}, tcArgs2...)..., + ).Scan(&agentLinks) + + return +} diff --git a/internal/store/pg/agents_export_team_standalone.go b/internal/store/pg/agents_export_team_standalone.go new file mode 100644 index 00000000..90678280 --- /dev/null +++ b/internal/store/pg/agents_export_team_standalone.go @@ -0,0 +1,300 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "log/slog" + + "github.com/google/uuid" +) + +// ExportTeamByID fetches team metadata by team_id directly (not via lead_agent_id). +func ExportTeamByID(ctx context.Context, db *sql.DB, teamID uuid.UUID) (*TeamExport, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + var t TeamExport + err = db.QueryRowContext(ctx, + "SELECT name, COALESCE(description,''), status, COALESCE(settings,'{}')"+ + " FROM agent_teams WHERE id = $1"+tc, + append([]any{teamID}, tcArgs...)..., + ).Scan(&t.Name, &t.Description, &t.Status, &t.Settings) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &t, nil +} + +// GetTeamMemberAgents returns all agents (id + agent_key) in a team. +func GetTeamMemberAgents(ctx context.Context, db *sql.DB, teamID uuid.UUID) ([]struct { + ID uuid.UUID + AgentKey string +}, error) { + tc, tcArgs, _, err := scopeClauseAlias(ctx, 2, "m") + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT a.id, a.agent_key"+ + " FROM agent_team_members m"+ + " JOIN agents a ON a.id = m.agent_id"+ + " WHERE m.team_id = $1"+tc, + append([]any{teamID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []struct { + ID uuid.UUID + AgentKey string + } + for rows.Next() { + var item struct { + ID uuid.UUID + AgentKey string + } + if err := rows.Scan(&item.ID, &item.AgentKey); err != nil { + slog.Warn("export.team.member_agents.scan", "error", err) + continue + } + out = append(out, item) + } + return out, rows.Err() +} + +// ExportTeamLinksForTeam returns agent_links where both source and target are members of the team. +func ExportTeamLinksForTeam(ctx context.Context, db *sql.DB, teamID uuid.UUID) ([]AgentLinkExport, error) { + tc, tcArgs, _, err := scopeClauseAlias(ctx, 2, "l") + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT sa.agent_key, ta.agent_key, l.direction, COALESCE(l.description,'')"+ + " FROM agent_links l"+ + " JOIN agents sa ON sa.id = l.source_agent_id"+ + " JOIN agents ta ON ta.id = l.target_agent_id"+ + " WHERE l.source_agent_id IN (SELECT agent_id FROM agent_team_members WHERE team_id = $1)"+ + " AND l.target_agent_id IN (SELECT agent_id FROM agent_team_members WHERE team_id = $1)"+tc, + append([]any{teamID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AgentLinkExport + for rows.Next() { + var l AgentLinkExport + if err := rows.Scan(&l.SourceAgentKey, &l.TargetAgentKey, &l.Direction, &l.Description); err != nil { + slog.Warn("export.team.links.scan", "error", err) + continue + } + out = append(out, l) + } + return out, rows.Err() +} + +// ExportTeamPreviewCountsByID returns task/member/link counts for a team by team_id. +func ExportTeamPreviewCountsByID(ctx context.Context, db *sql.DB, teamID uuid.UUID) (tasks, members, links int, err error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return + } + args := append([]any{teamID}, tcArgs...) + + _ = db.QueryRowContext(ctx, + "SELECT"+ + " (SELECT COUNT(*) FROM team_tasks WHERE team_id = $1"+tc+"),"+ + " (SELECT COUNT(*) FROM agent_team_members WHERE team_id = $1"+tc+"),"+ + " (SELECT COUNT(*) FROM agent_links"+ + " WHERE source_agent_id IN (SELECT agent_id FROM agent_team_members WHERE team_id = $1"+tc+")"+ + " AND target_agent_id IN (SELECT agent_id FROM agent_team_members WHERE team_id = $1"+tc+")"+tc+")", + args..., + ).Scan(&tasks, &members, &links) + return +} + +// ExportTeamMembersNonLead returns all non-lead members by team_id (no lead exclusion needed when doing standalone team export). +func ExportTeamMembersNonLead(ctx context.Context, db *sql.DB, teamID uuid.UUID, leadAgentID uuid.UUID) ([]TeamMemberExport, error) { + tc, tcArgs, _, err := scopeClauseAlias(ctx, 3, "m") + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT a.agent_key, m.role"+ + " FROM agent_team_members m"+ + " JOIN agents a ON a.id = m.agent_id"+ + " WHERE m.team_id = $1 AND m.agent_id != $2"+tc, + append([]any{teamID, leadAgentID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []TeamMemberExport + for rows.Next() { + var m TeamMemberExport + if err := rows.Scan(&m.AgentKey, &m.Role); err != nil { + slog.Warn("export.team.members_non_lead.scan", "error", err) + continue + } + out = append(out, m) + } + return out, rows.Err() +} + +// ExportTeamLeadAgentID returns the lead agent_id for a team, or uuid.Nil if not found. +func ExportTeamLeadAgentID(ctx context.Context, db *sql.DB, teamID uuid.UUID) (uuid.UUID, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return uuid.Nil, err + } + var leadID uuid.UUID + err = db.QueryRowContext(ctx, + "SELECT lead_agent_id FROM agent_teams WHERE id = $1"+tc, + append([]any{teamID}, tcArgs...)..., + ).Scan(&leadID) + if err == sql.ErrNoRows { + return uuid.Nil, nil + } + return leadID, err +} + +// ExportTeamAgentConfig returns agent metadata (id, agent_key, display_name, provider, model, etc.) +// as a raw JSON-serializable map using marshalAgentConfigExport helper. Returned as json.RawMessage. +func ExportTeamAgentBasicInfo(ctx context.Context, db *sql.DB, agentID uuid.UUID) (agentKey string, err error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return "", err + } + err = db.QueryRowContext(ctx, + "SELECT agent_key FROM agents WHERE id = $1"+tc, + append([]any{agentID}, tcArgs...)..., + ).Scan(&agentKey) + if err == sql.ErrNoRows { + return "", nil + } + return agentKey, err +} + +// ExportTeamMembersAll returns ALL members of a team (including lead) with agent_key resolution. +func ExportTeamMembersAll(ctx context.Context, db *sql.DB, teamID uuid.UUID) ([]TeamMemberExport, error) { + tc, tcArgs, _, err := scopeClauseAlias(ctx, 2, "m") + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT a.agent_key, m.role"+ + " FROM agent_team_members m"+ + " JOIN agents a ON a.id = m.agent_id"+ + " WHERE m.team_id = $1"+tc, + append([]any{teamID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []TeamMemberExport + for rows.Next() { + var m TeamMemberExport + if err := rows.Scan(&m.AgentKey, &m.Role); err != nil { + slog.Warn("export.team.all_members.scan", "error", err) + continue + } + out = append(out, m) + } + return out, rows.Err() +} + +// exportTeamAgentFullData returns a fully-serializable agent config for team export. +func ExportTeamAgentJSON(ctx context.Context, db *sql.DB, agentID uuid.UUID) (json.RawMessage, error) { + tc, tcArgs, _, err := scopeClause(ctx, 2) + if err != nil { + return nil, err + } + + var ( + agentKey string + displayName string + frontmatter string + provider string + model string + contextWindow int + maxToolIterations int + agentType string + status string + toolsConfig []byte + sandboxConfig []byte + subagentsConfig []byte + memoryConfig []byte + compactionConfig []byte + contextPruning []byte + otherConfig []byte + ) + + err = db.QueryRowContext(ctx, + "SELECT agent_key, COALESCE(display_name,''), COALESCE(frontmatter,''),"+ + " COALESCE(provider,''), COALESCE(model,''),"+ + " context_window, max_tool_iterations, COALESCE(agent_type,'open'), COALESCE(status,'active'),"+ + " tools_config, sandbox_config, subagents_config, memory_config, compaction_config,"+ + " context_pruning, other_config"+ + " FROM agents WHERE id = $1"+tc, + append([]any{agentID}, tcArgs...)..., + ).Scan( + &agentKey, &displayName, &frontmatter, + &provider, &model, + &contextWindow, &maxToolIterations, &agentType, &status, + &toolsConfig, &sandboxConfig, &subagentsConfig, &memoryConfig, &compactionConfig, + &contextPruning, &otherConfig, + ) + if err != nil { + return nil, err + } + + type exportableAgent struct { + AgentKey string `json:"agent_key"` + DisplayName string `json:"display_name,omitempty"` + Frontmatter string `json:"frontmatter,omitempty"` + Provider string `json:"provider"` + Model string `json:"model"` + ContextWindow int `json:"context_window"` + MaxToolIterations int `json:"max_tool_iterations"` + AgentType string `json:"agent_type"` + Status string `json:"status"` + ToolsConfig json.RawMessage `json:"tools_config,omitempty"` + SandboxConfig json.RawMessage `json:"sandbox_config,omitempty"` + SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"` + MemoryConfig json.RawMessage `json:"memory_config,omitempty"` + CompactionConfig json.RawMessage `json:"compaction_config,omitempty"` + ContextPruning json.RawMessage `json:"context_pruning,omitempty"` + OtherConfig json.RawMessage `json:"other_config,omitempty"` + } + raw, err := json.Marshal(exportableAgent{ + AgentKey: agentKey, + DisplayName: displayName, + Frontmatter: frontmatter, + Provider: provider, + Model: model, + ContextWindow: contextWindow, + MaxToolIterations: maxToolIterations, + AgentType: agentType, + Status: status, + ToolsConfig: toolsConfig, + SandboxConfig: sandboxConfig, + SubagentsConfig: subagentsConfig, + MemoryConfig: memoryConfig, + CompactionConfig: compactionConfig, + ContextPruning: contextPruning, + OtherConfig: otherConfig, + }) + return raw, err +} diff --git a/internal/store/pg/mcp_export_queries.go b/internal/store/pg/mcp_export_queries.go new file mode 100644 index 00000000..43f4e147 --- /dev/null +++ b/internal/store/pg/mcp_export_queries.go @@ -0,0 +1,218 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "log/slog" + + "github.com/google/uuid" +) + +// MCPServerExport holds portable MCP server metadata. +// Sensitive fields (api_key, env, headers) are intentionally excluded. +type MCPServerExport struct { + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + Transport string `json:"transport"` + Command string `json:"command,omitempty"` + Args json.RawMessage `json:"args,omitempty"` + URL string `json:"url,omitempty"` + ToolPrefix string `json:"tool_prefix,omitempty"` + TimeoutSec int `json:"timeout_sec"` + Settings json.RawMessage `json:"settings,omitempty"` + Enabled bool `json:"enabled"` +} + +// MCPGrantWithKey references an MCP agent grant by server_name and agent_key (portable). +type MCPGrantWithKey struct { + ServerName string `json:"server_name"` + AgentKey string `json:"agent_key"` + Enabled bool `json:"enabled"` + ToolAllow json.RawMessage `json:"tool_allow,omitempty"` + ToolDeny json.RawMessage `json:"tool_deny,omitempty"` + ConfigOverrides json.RawMessage `json:"config_overrides,omitempty"` +} + +// MCPExportPreview holds lightweight counts for the export preview endpoint. +type MCPExportPreview struct { + Servers int `json:"servers"` + AgentGrants int `json:"agent_grants"` +} + +// ExportMCPServers returns all MCP servers scoped to the current tenant. +// Sensitive fields (api_key, headers, env) are excluded from the export. +func ExportMCPServers(ctx context.Context, db *sql.DB) ([]MCPServerExport, error) { + tc, tcArgs, _, err := scopeClause(ctx, 1) + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT name, COALESCE(display_name,''), transport,"+ + " COALESCE(command,''), args, COALESCE(url,''),"+ + " COALESCE(tool_prefix,''), timeout_sec, settings, enabled"+ + " FROM mcp_servers WHERE 1=1"+tc+ + " ORDER BY name", + tcArgs..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []MCPServerExport + for rows.Next() { + var ( + srv MCPServerExport + argsRaw []byte + settings []byte + ) + if err := rows.Scan( + &srv.Name, &srv.DisplayName, &srv.Transport, + &srv.Command, &argsRaw, &srv.URL, + &srv.ToolPrefix, &srv.TimeoutSec, &settings, &srv.Enabled, + ); err != nil { + slog.Warn("mcp_export.servers.scan", "error", err) + continue + } + if len(argsRaw) > 0 { + srv.Args = json.RawMessage(argsRaw) + } + if len(settings) > 0 { + srv.Settings = json.RawMessage(settings) + } + result = append(result, srv) + } + return result, rows.Err() +} + +// ExportMCPGrantsWithKeys returns all MCP agent grants with server_name and agent_key resolved. +func ExportMCPGrantsWithKeys(ctx context.Context, db *sql.DB) ([]MCPGrantWithKey, error) { + tc, tcArgs, _, err := scopeClauseAlias(ctx, 1, "g") + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT s.name, a.agent_key, g.enabled, g.tool_allow, g.tool_deny, g.config_overrides"+ + " FROM mcp_agent_grants g"+ + " JOIN mcp_servers s ON s.id = g.server_id"+ + " JOIN agents a ON a.id = g.agent_id"+ + " WHERE 1=1"+tc+ + " ORDER BY s.name, a.agent_key", + tcArgs..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []MCPGrantWithKey + for rows.Next() { + var ( + g MCPGrantWithKey + toolAllow []byte + toolDeny []byte + configOverrides []byte + ) + if err := rows.Scan( + &g.ServerName, &g.AgentKey, &g.Enabled, + &toolAllow, &toolDeny, &configOverrides, + ); err != nil { + slog.Warn("mcp_export.grants.scan", "error", err) + continue + } + if len(toolAllow) > 0 { + g.ToolAllow = json.RawMessage(toolAllow) + } + if len(toolDeny) > 0 { + g.ToolDeny = json.RawMessage(toolDeny) + } + if len(configOverrides) > 0 { + g.ConfigOverrides = json.RawMessage(configOverrides) + } + result = append(result, g) + } + return result, rows.Err() +} + +// ExportMCPPreview returns aggregate counts for MCP export preview. +func ExportMCPPreview(ctx context.Context, db *sql.DB) (*MCPExportPreview, error) { + tc, tcArgs, _, err := scopeClause(ctx, 1) + if err != nil { + return nil, err + } + var p MCPExportPreview + if err := db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM mcp_servers WHERE 1=1"+tc, + tcArgs..., + ).Scan(&p.Servers); err != nil { + return nil, err + } + + tc2, tcArgs2, _, err := scopeClause(ctx, 1) + if err != nil { + return nil, err + } + if err := db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM mcp_agent_grants WHERE 1=1"+tc2, + tcArgs2..., + ).Scan(&p.AgentGrants); err != nil { + return nil, err + } + return &p, nil +} + +// ImportMCPServer inserts an MCP server from export data if no server with that name exists. +// Returns the server UUID and whether it was newly created (false = already existed). +func ImportMCPServer(ctx context.Context, db *sql.DB, srv MCPServerExport, createdBy string) (uuid.UUID, bool, error) { + tid := tenantIDForInsert(ctx) + + // Check if server with same name already exists + var existing uuid.UUID + err := db.QueryRowContext(ctx, + "SELECT id FROM mcp_servers WHERE name = $1 AND tenant_id = $2", + srv.Name, tid, + ).Scan(&existing) + if err == nil { + return existing, false, nil // already exists — return existing ID + } + if err != sql.ErrNoRows { + return uuid.Nil, false, err + } + + id := uuid.Must(uuid.NewV7()) + _, err = db.ExecContext(ctx, + `INSERT INTO mcp_servers + (id, name, display_name, transport, command, args, url, + tool_prefix, timeout_sec, settings, enabled, + created_by, created_at, updated_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),NOW(),$13)`, + id, srv.Name, srv.DisplayName, srv.Transport, + srv.Command, jsonOrNull(srv.Args), srv.URL, + srv.ToolPrefix, srv.TimeoutSec, jsonOrNull(srv.Settings), srv.Enabled, + createdBy, tid, + ) + if err != nil { + return uuid.Nil, false, err + } + return id, true, nil +} + +// ImportMCPGrant upserts an MCP agent grant for an imported server. +func ImportMCPGrant(ctx context.Context, db *sql.DB, serverID, agentID uuid.UUID, g MCPGrantWithKey, grantedBy string) error { + tid := tenantIDForInsert(ctx) + _, err := db.ExecContext(ctx, + `INSERT INTO mcp_agent_grants + (id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9) + ON CONFLICT (server_id, agent_id) DO UPDATE + SET enabled = EXCLUDED.enabled, + tool_allow = EXCLUDED.tool_allow, + tool_deny = EXCLUDED.tool_deny, + config_overrides = EXCLUDED.config_overrides`, + uuid.Must(uuid.NewV7()), serverID, agentID, g.Enabled, + jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides), + grantedBy, tid, + ) + return err +} diff --git a/internal/store/pg/skills_export_queries.go b/internal/store/pg/skills_export_queries.go new file mode 100644 index 00000000..b75ab530 --- /dev/null +++ b/internal/store/pg/skills_export_queries.go @@ -0,0 +1,200 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "log/slog" + + "github.com/google/uuid" + "github.com/lib/pq" +) + +// CustomSkillExport holds portable skill metadata (no internal UUIDs in references). +type CustomSkillExport struct { + ID string `json:"id"` // UUID string — used as key for grant references within archive + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description,omitempty"` + Visibility string `json:"visibility"` + Version int `json:"version"` + Frontmatter json.RawMessage `json:"frontmatter,omitempty"` + Tags []string `json:"tags,omitempty"` + Deps json.RawMessage `json:"deps,omitempty"` + FilePath string `json:"file_path,omitempty"` // original path — for reading file content +} + +// SkillGrantWithKey references a skill grant by agent_key (portable cross-system). +type SkillGrantWithKey struct { + AgentKey string `json:"agent_key"` + PinnedVersion int `json:"pinned_version"` +} + +// SkillsExportPreview holds lightweight counts for the export preview endpoint. +type SkillsExportPreview struct { + CustomSkills int `json:"custom_skills"` + TotalGrants int `json:"total_grants"` +} + +// ExportCustomSkills returns all non-system skills scoped to the current tenant. +func ExportCustomSkills(ctx context.Context, db *sql.DB) ([]CustomSkillExport, error) { + tc, tcArgs, _, err := scopeClause(ctx, 1) + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT id, name, slug, description, visibility, version, frontmatter, tags, deps, file_path"+ + " FROM skills WHERE is_system = false"+tc+ + " ORDER BY name", + tcArgs..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []CustomSkillExport + for rows.Next() { + var ( + id uuid.UUID + name string + slug string + desc *string + visibility string + version int + fmRaw []byte + tags []string + depsRaw []byte + filePath *string + ) + if err := rows.Scan(&id, &name, &slug, &desc, &visibility, &version, &fmRaw, pq.Array(&tags), &depsRaw, &filePath); err != nil { + slog.Warn("skills_export.scan", "error", err) + continue + } + sk := CustomSkillExport{ + ID: id.String(), + Name: name, + Slug: slug, + Description: desc, + Visibility: visibility, + Version: version, + Tags: tags, + } + if len(fmRaw) > 0 { + sk.Frontmatter = json.RawMessage(fmRaw) + } + if len(depsRaw) > 0 { + sk.Deps = json.RawMessage(depsRaw) + } + if filePath != nil { + sk.FilePath = *filePath + } + result = append(result, sk) + } + return result, rows.Err() +} + +// ExportSkillGrantsWithAgentKey returns all agent grants for a skill, resolved to agent_key. +func ExportSkillGrantsWithAgentKey(ctx context.Context, db *sql.DB, skillID uuid.UUID) ([]SkillGrantWithKey, error) { + tc, tcArgs, _, err := scopeClauseAlias(ctx, 2, "g") + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT a.agent_key, g.pinned_version"+ + " FROM skill_agent_grants g"+ + " JOIN agents a ON a.id = g.agent_id"+ + " WHERE g.skill_id = $1"+tc, + append([]any{skillID}, tcArgs...)..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []SkillGrantWithKey + for rows.Next() { + var g SkillGrantWithKey + if err := rows.Scan(&g.AgentKey, &g.PinnedVersion); err != nil { + slog.Warn("skills_export.grants.scan", "error", err) + continue + } + result = append(result, g) + } + return result, rows.Err() +} + +// ExportSkillsPreview returns aggregate counts for skills export preview. +// Uses two separate queries to avoid parameter index complexity with repeated scope clauses. +func ExportSkillsPreview(ctx context.Context, db *sql.DB) (*SkillsExportPreview, error) { + tc, tcArgs, _, err := scopeClause(ctx, 1) + if err != nil { + return nil, err + } + + var p SkillsExportPreview + if err := db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM skills WHERE is_system = false"+tc, + tcArgs..., + ).Scan(&p.CustomSkills); err != nil { + return nil, err + } + + tc2, tcArgs2, _, err := scopeClauseAlias(ctx, 1, "g") + if err != nil { + return nil, err + } + if err := db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM skill_agent_grants g"+ + " JOIN skills s ON s.id = g.skill_id"+ + " WHERE s.is_system = false"+tc2, + tcArgs2..., + ).Scan(&p.TotalGrants); err != nil { + return nil, err + } + return &p, nil +} + +// ExportSkillGrantAgentKeys returns all unique agent_keys that have grants for non-system skills. +// Used for import to pre-resolve agent_key → agent_id. +func ExportSkillGrantAgentKeys(ctx context.Context, db *sql.DB) ([]string, error) { + tc, tcArgs, _, err := scopeClauseAlias(ctx, 1, "g") + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, + "SELECT DISTINCT a.agent_key"+ + " FROM skill_agent_grants g"+ + " JOIN skills s ON s.id = g.skill_id"+ + " JOIN agents a ON a.id = g.agent_id"+ + " WHERE s.is_system = false"+tc, + tcArgs..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []string + for rows.Next() { + var key string + if err := rows.Scan(&key); err != nil { + continue + } + out = append(out, key) + } + return out, rows.Err() +} + +// ImportSkillGrant upserts an agent grant for an imported skill. +// Uses ON CONFLICT to handle re-imports gracefully. +func ImportSkillGrant(ctx context.Context, db *sql.DB, skillID, agentID uuid.UUID, pinnedVersion int, grantedBy string) error { + tid := tenantIDForInsert(ctx) + _, err := db.ExecContext(ctx, + `INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, NOW(), $6) + ON CONFLICT (skill_id, agent_id) DO UPDATE SET pinned_version = EXCLUDED.pinned_version`, + uuid.Must(uuid.NewV7()), skillID, agentID, pinnedVersion, grantedBy, tid, + ) + return err +} diff --git a/ui/web/src/api/http-client.ts b/ui/web/src/api/http-client.ts index 2b73dc7b..0e71d7d9 100644 --- a/ui/web/src/api/http-client.ts +++ b/ui/web/src/api/http-client.ts @@ -102,6 +102,11 @@ export class HttpClient { return url.toString(); } + /** Public auth headers — for SSE streams and custom fetch calls. */ + getAuthHeaders(): Record { + return this.authHeaders(); + } + /** Auth-only headers (no Content-Type), for SSE / blob requests. */ private authHeaders(): Record { const h: Record = {}; diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx index 7f47af26..3b42745b 100644 --- a/ui/web/src/components/layout/sidebar.tsx +++ b/ui/web/src/components/layout/sidebar.tsx @@ -26,6 +26,7 @@ import { Contact, KeyRound, Building2, + ArrowLeftRight, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { SidebarGroup } from "./sidebar-group"; @@ -133,6 +134,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) { )} + )} diff --git a/ui/web/src/components/shared/operation-progress.tsx b/ui/web/src/components/shared/operation-progress.tsx new file mode 100644 index 00000000..2bead77c --- /dev/null +++ b/ui/web/src/components/shared/operation-progress.tsx @@ -0,0 +1,78 @@ +import { CheckCircle2, XCircle, Loader2, Circle } from "lucide-react"; + +export interface ProgressStep { + id: string; + label: string; + status: "pending" | "running" | "done" | "error"; + detail?: string; + current?: number; + total?: number; + errorMessage?: string; +} + +interface OperationProgressProps { + steps: ProgressStep[]; + elapsed?: number; + className?: string; +} + +function StepIcon({ status }: { status: ProgressStep["status"] }) { + switch (status) { + case "done": + return ; + case "error": + return ; + case "running": + return ; + default: + return ; + } +} + +export function OperationProgress({ steps, elapsed, className }: OperationProgressProps) { + return ( +
+ {steps.map((step) => ( +
+
+ + + {step.label} + + + {step.detail + ? step.detail + : step.status === "done" && step.total != null && step.total > 0 + ? `${step.total} items` + : null} + +
+ + {step.status === "running" && step.total != null && step.total > 0 && ( +
+
+
+
+ + {step.current ?? 0}/{step.total} + +
+ )} + + {step.status === "error" && step.errorMessage && ( +

{step.errorMessage}

+ )} +
+ ))} + + {elapsed != null && ( +
+ {elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed / 60)}m ${elapsed % 60}s`} +
+ )} +
+ ); +} diff --git a/ui/web/src/hooks/use-sse-progress.ts b/ui/web/src/hooks/use-sse-progress.ts new file mode 100644 index 00000000..54164143 --- /dev/null +++ b/ui/web/src/hooks/use-sse-progress.ts @@ -0,0 +1,220 @@ +import { useState, useRef, useCallback, useEffect } from "react"; +import type { ProgressStep } from "@/components/shared/operation-progress"; + +/** Server-sent progress event from export/import endpoints. */ +interface SseProgressEvent { + phase: string; + status: "running" | "done" | "error"; + detail?: string; + current?: number; + total?: number; +} + +export interface SseCompleteEvent { + download_url?: string; + file_size?: number; + file_name?: string; + agent_id?: string; + agent_key?: string; + summary?: Record; + [key: string]: unknown; +} + +interface SseErrorEvent { + phase: string; + detail: string; + rolled_back: boolean; + cleanup?: { db?: string; files?: string }; +} + +export type SseStatus = "idle" | "running" | "complete" | "error"; + +export interface UseSseProgressReturn { + steps: ProgressStep[]; + status: SseStatus; + error: SseErrorEvent | null; + elapsed: number; + result: SseCompleteEvent | null; + startGet: (url: string) => void; + startPost: (url: string, body: FormData) => void; + cancel: () => void; + reset: () => void; +} + +/** + * Generic hook for SSE progress tracking. + * Parses `event: progress`, `event: complete`, `event: error` from streaming response. + * Used by both export and import flows. + */ +export function useSseProgress(authHeaders: () => Record): UseSseProgressReturn { + const [steps, setSteps] = useState([]); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [elapsed, setElapsed] = useState(0); + const [result, setResult] = useState(null); + + const abortRef = useRef(null); + const timerRef = useRef | null>(null); + + const cleanup = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + useEffect(() => cleanup, [cleanup]); + + const startTimer = useCallback(() => { + const t0 = Date.now(); + timerRef.current = setInterval(() => { + setElapsed(Math.floor((Date.now() - t0) / 1000)); + }, 1000); + }, []); + + const handleProgress = useCallback((evt: SseProgressEvent) => { + setSteps((prev) => { + const idx = prev.findIndex((s) => s.id === evt.phase); + const step: ProgressStep = { + id: evt.phase, + label: evt.phase.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), + status: evt.status === "error" ? "error" : evt.status === "done" ? "done" : "running", + detail: evt.detail, + current: evt.current, + total: evt.total, + }; + if (idx >= 0) { + const next = [...prev]; + next[idx] = step; + return next; + } + return [...prev, step]; + }); + }, []); + + const processStream = useCallback( + async (res: Response) => { + const reader = res.body?.getReader(); + if (!reader) return; + + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + let eventType = ""; + let dataStr = ""; + + for (const line of lines) { + if (line.startsWith("event: ")) { + eventType = line.slice(7).trim(); + } else if (line.startsWith("data: ")) { + dataStr = line.slice(6); + } else if (line === "" && eventType && dataStr) { + try { + const data = JSON.parse(dataStr); + if (eventType === "progress") { + handleProgress(data as SseProgressEvent); + } else if (eventType === "complete") { + setResult(data as SseCompleteEvent); + setStatus("complete"); + cleanup(); + } else if (eventType === "error") { + setError(data as SseErrorEvent); + setStatus("error"); + cleanup(); + } + } catch { /* skip malformed data */ } + eventType = ""; + dataStr = ""; + } + } + } + } catch (e) { + if ((e as Error).name !== "AbortError") { + setError({ phase: "connection", detail: (e as Error).message, rolled_back: false }); + setStatus("error"); + } + } finally { + cleanup(); + } + }, + [handleProgress, cleanup], + ); + + const doFetch = useCallback( + async (url: string, init: RequestInit) => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setSteps([]); + setStatus("running"); + setError(null); + setResult(null); + setElapsed(0); + startTimer(); + + try { + const res = await fetch(url, { ...init, signal: controller.signal }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + setError({ phase: "request", detail: err.error ?? res.statusText, rolled_back: false }); + setStatus("error"); + cleanup(); + return; + } + await processStream(res); + } catch (e) { + if ((e as Error).name !== "AbortError") { + setError({ phase: "network", detail: (e as Error).message, rolled_back: false }); + setStatus("error"); + cleanup(); + } + } + }, + [startTimer, processStream, cleanup], + ); + + const startGet = useCallback( + (url: string) => { + doFetch(url, { method: "GET", headers: authHeaders() }); + }, + [doFetch, authHeaders], + ); + + const startPost = useCallback( + (url: string, body: FormData) => { + doFetch(url, { method: "POST", headers: authHeaders(), body }); + }, + [doFetch, authHeaders], + ); + + const cancel = useCallback(() => { + abortRef.current?.abort(); + cleanup(); + if (status === "running") { + setStatus("error"); + setError({ phase: "cancelled", detail: "Operation cancelled by user", rolled_back: false }); + } + }, [status, cleanup]); + + const reset = useCallback(() => { + abortRef.current?.abort(); + cleanup(); + setSteps([]); + setStatus("idle"); + setError(null); + setResult(null); + setElapsed(0); + }, [cleanup]); + + return { steps, status, error, elapsed, result, startGet, startPost, cancel, reset }; +} diff --git a/ui/web/src/i18n/index.ts b/ui/web/src/i18n/index.ts index b11bceeb..56b1e1e3 100644 --- a/ui/web/src/i18n/index.ts +++ b/ui/web/src/i18n/index.ts @@ -36,6 +36,7 @@ import enCliCredentials from "./locales/en/cli-credentials.json"; import enPackages from "./locales/en/packages.json"; import enTenants from "./locales/en/tenants.json"; import enSystemSettings from "./locales/en/system-settings.json"; +import enImportExport from "./locales/en/import-export.json"; // --- VI namespaces --- import viCommon from "./locales/vi/common.json"; @@ -72,6 +73,7 @@ import viCliCredentials from "./locales/vi/cli-credentials.json"; import viPackages from "./locales/vi/packages.json"; import viTenants from "./locales/vi/tenants.json"; import viSystemSettings from "./locales/vi/system-settings.json"; +import viImportExport from "./locales/vi/import-export.json"; // --- ZH namespaces --- import zhCommon from "./locales/zh/common.json"; @@ -108,6 +110,7 @@ import zhCliCredentials from "./locales/zh/cli-credentials.json"; import zhPackages from "./locales/zh/packages.json"; import zhTenants from "./locales/zh/tenants.json"; import zhSystemSettings from "./locales/zh/system-settings.json"; +import zhImportExport from "./locales/zh/import-export.json"; const STORAGE_KEY = "goclaw:language"; @@ -126,7 +129,7 @@ const ns = [ "channels", "providers", "traces", "events", "usage", "approvals", "nodes", "logs", "tools", "mcp", "tts", "setup", "memory", "storage", "pending-messages", "contacts", "activity", "api-keys", - "cli-credentials", "packages", "tenants", "system-settings", + "cli-credentials", "packages", "tenants", "system-settings", "import-export", ] as const; i18n.use(initReactI18next).init({ @@ -145,6 +148,7 @@ i18n.use(initReactI18next).init({ packages: enPackages, tenants: enTenants, "system-settings": enSystemSettings, + "import-export": enImportExport, }, vi: { common: viCommon, sidebar: viSidebar, topbar: viTopbar, login: viLogin, @@ -160,6 +164,7 @@ i18n.use(initReactI18next).init({ packages: viPackages, tenants: viTenants, "system-settings": viSystemSettings, + "import-export": viImportExport, }, zh: { common: zhCommon, sidebar: zhSidebar, topbar: zhTopbar, login: zhLogin, @@ -175,6 +180,7 @@ i18n.use(initReactI18next).init({ packages: zhPackages, tenants: zhTenants, "system-settings": zhSystemSettings, + "import-export": zhImportExport, }, }, ns: [...ns], diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index 948e4e05..df576e76 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -753,6 +753,74 @@ "global": "Global (all contexts)" } }, + "transfer": { + "title": "Agent Transfer", + "description": "Export agents to archive files or import from existing archives.", + "tabExport": "Export", + "tabImport": "Import", + "selectAgent": "Select Agent", + "selectAgentPlaceholder": "Choose an agent...", + "presets": "Presets", + "preset": { + "minimal": "Minimal", + "standard": "Standard", + "complete": "Complete" + }, + "sections": { + "config": "Agent Config", + "contextFiles": "Context Files", + "userData": "User Data", + "userContextFiles": "User context files", + "userProfiles": "User profiles", + "userOverrides": "User overrides", + "memory": "Memory", + "memoryGlobal": "Global", + "memoryPerUser": "Per-user", + "knowledgeGraph": "Knowledge Graph", + "skills": "Skills", + "mcp": "MCP Grants", + "customTools": "Custom Tools", + "cron": "Cron Jobs", + "permissions": "Permissions", + "sessions": "Sessions", + "workspace": "Workspace Files", + "team": "Team", + "media": "Media Files" + }, + "hints": { + "sessions": "Can be very large", + "media": "Can be very large", + "team": "Team metadata, members, tasks, links", + "mergeOverwrite": "Existing files with same name will be overwritten", + "mergeUpsert": "Documents upserted by path", + "mergeKgUpsert": "Entities upserted by external_id", + "mergeFiles": "New files added, existing overwritten" + }, + "exportButton": "Export Agent", + "exporting": "Exporting \"{{name}}\"...", + "exportComplete": "Export Complete", + "exportFailed": "Export Failed", + "exportAnother": "Export Another", + "download": "Download", + "importing": "Importing...", + "importComplete": "Import Complete", + "importFailed": "Import Failed", + "importAnother": "Import Another", + "viewAgent": "View Agent", + "tryAgain": "Try Again", + "cancel": "Cancel", + "dontClose": "Don't close this page — import in progress", + "rolledBack": "All changes have been rolled back. System is in previous state.", + "dropHere": "Drop .tar.gz file here or click to browse", + "supportedFormats": "Supports: .tar.gz, .agent.json (legacy)", + "invalidFile": "Invalid file — could not parse archive", + "importMode": "Import Mode", + "modeNew": "Create new agent", + "modeMerge": "Merge into existing agent", + "targetAgent": "Target Agent", + "changeFile": "Change File", + "startImport": "Start Import" + }, "toast": { "created": "Agent created", "createFailed": "Failed to create agent", diff --git a/ui/web/src/i18n/locales/en/import-export.json b/ui/web/src/i18n/locales/en/import-export.json new file mode 100644 index 00000000..1f9686df --- /dev/null +++ b/ui/web/src/i18n/locales/en/import-export.json @@ -0,0 +1,131 @@ +{ + "title": "Import & Export", + "description": "Export agents to a portable archive or import from one.", + "beta": "Beta", + "betaWarning": "Import & Export is in beta. Always keep a backup before importing into an existing agent.", + "tabs": { + "teams": "Teams", + "agents": "Agents", + "skillsMcp": "Skills & MCP", + "export": "Export", + "import": "Import" + }, + "comingSoon": { + "title": "Coming Soon", + "description": "This section is not yet available." + }, + "export": { + "title": "Export Agent", + "agent": "Agent", + "agentPlaceholder": "Select agent", + "sections": "Sections", + "preset": "Preset", + "presetsLabel": "Presets", + "infoNote": "Use the Teams tab to export agents together with their team.", + "startExport": "Export", + "exporting": "Exporting...", + "preview": "Preview", + "previewLoading": "Loading preview...", + "download": "Download", + "done": "Export complete", + "errorTitle": "Export Failed", + "selectAgent": "Select an agent to export.", + "noAgents": "No agents available." + }, + "import": { + "title": "Import Agent", + "mode": "Mode", + "modeNew": "Create new agent", + "modeMerge": "Merge into existing", + "agentKey": "Agent Key", + "agentKeyPlaceholder": "e.g. my-agent", + "agentKeyHint": "Lowercase, numbers, hyphens. Leave empty to use the key from the archive.", + "targetAgent": "Target Agent", + "targetAgentPlaceholder": "Select target agent", + "file": "Archive File (.tar.gz)", + "filePlaceholder": "Click to select or drag & drop", + "sections": "Sections to Merge", + "startImport": "Import", + "importing": "Importing...", + "done": "Import complete", + "agentCreated": "Agent created: {{key}}", + "mergedInto": "Merged into: {{key}}", + "errorTitle": "Import Failed", + "manifestLabel": "Archive info", + "agentFrom": "Agent from archive: {{key}}", + "exportedAt": "Exported: {{date}}" + }, + "sections": { + "config": "Config", + "configDesc": "Agent configuration (model, tools, etc.)", + "context_files": "Context Files", + "context_filesDesc": "Agent-level context files (SOUL.md, IDENTITY.md, etc.)", + "user_data": "User Data", + "user_dataDesc": "All per-user data (context files, profiles, overrides)", + "user_context_files": "User Context Files", + "user_context_filesDesc": "Per-user context files (USER.md per user)", + "user_profiles": "User Profiles", + "user_profilesDesc": "User workspace profile records", + "user_overrides": "User Overrides", + "user_overridesDesc": "Per-user model/provider overrides", + "memory": "Memory", + "memoryDesc": "All memory documents (global + per-user)", + "memory_global": "Global Memory", + "memory_globalDesc": "Shared memory documents", + "memory_per_user": "Per-User Memory", + "memory_per_userDesc": "Individual user memory documents", + "knowledge_graph": "Knowledge Graph", + "knowledge_graphDesc": "KG entities and relations", + "cron": "Cron Jobs", + "cronDesc": "Scheduled tasks (imported as disabled)", + "workspace": "Workspace Files", + "workspaceDesc": "Agent workspace directory files", + "sessions": "Sessions", + "sessionsDesc": "Chat sessions and messages", + "media": "Media", + "mediaDesc": "Uploaded media files", + "comingSoon": "Coming soon" + }, + "presets": { + "minimal": "Minimal", + "standard": "Standard", + "complete": "Complete" + }, + "teamExportNote": "Team export includes all member agents and their data (context files, memory, knowledge graph, cron, workspace).", + "teamSelectPlaceholder": "Select a team...", + "teamDropHere": "Drop team archive file here or click to browse", + "teamDropFormats": "Supports .tar.gz files", + "teamDontClose": "Don't close this page — import in progress", + "teamImportAnother": "Import Another", + "teamTryAgain": "Try Again", + "teamChangeFile": "Change File", + "teamPreview": { + "members": "{{count}} members", + "agents": "{{count}} agents", + "tasks": "{{count}} tasks", + "links": "{{count}} links", + "loading": "Loading preview...", + "error": "Failed to load preview" + }, + "skillsMcp": { + "skillsTab": "Skills", + "mcpTab": "MCP Servers", + "skillsNote": "Only custom skills (non-system) are exported. System skills are not included.", + "mcpNote": "MCP server configs are exported without API keys, env vars, or headers. You'll need to re-enter secrets after import.", + "customSkills": "{{count}} custom skills", + "grants": "{{count}} agent grants", + "servers": "{{count}} servers", + "noSkills": "No custom skills to export.", + "noServers": "No MCP servers to export.", + "exportSkills": "Export Skills", + "exportMcp": "Export MCP Servers", + "importSkills": "Import Skills", + "importMcp": "Import MCP Servers", + "importedSkills": "Skills imported", + "skippedSkills": "Skills skipped (already exist)", + "importedServers": "Servers imported", + "skippedServers": "Servers skipped (already exist)", + "dropSkills": "Drop skills archive file here or click to browse", + "dropMcp": "Drop MCP archive file here or click to browse" + } +} diff --git a/ui/web/src/i18n/locales/en/sidebar.json b/ui/web/src/i18n/locales/en/sidebar.json index 19460cea..f988a357 100644 --- a/ui/web/src/i18n/locales/en/sidebar.json +++ b/ui/web/src/i18n/locales/en/sidebar.json @@ -32,6 +32,7 @@ "providers": "Providers", "config": "Config", "approvals": "Approvals", + "importExport": "Import & Export", "nodes": "Nodes", "tts": "TTS", "activity": "Activity", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index 968a55d1..39a1158f 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -753,6 +753,74 @@ "global": "Toàn cục" } }, + "transfer": { + "title": "Chuyển Agent", + "description": "Xuất agent ra file archive hoặc nhập từ archive có sẵn.", + "tabExport": "Xuất", + "tabImport": "Nhập", + "selectAgent": "Chọn Agent", + "selectAgentPlaceholder": "Chọn agent...", + "presets": "Mẫu", + "preset": { + "minimal": "Tối thiểu", + "standard": "Tiêu chuẩn", + "complete": "Đầy đủ" + }, + "sections": { + "config": "Cấu hình Agent", + "contextFiles": "File ngữ cảnh", + "userData": "Dữ liệu người dùng", + "userContextFiles": "File ngữ cảnh người dùng", + "userProfiles": "Hồ sơ người dùng", + "userOverrides": "Tuỳ chỉnh người dùng", + "memory": "Bộ nhớ", + "memoryGlobal": "Toàn cục", + "memoryPerUser": "Theo người dùng", + "knowledgeGraph": "Đồ thị tri thức", + "skills": "Skills", + "mcp": "MCP Grants", + "customTools": "Custom Tools", + "cron": "Cron Jobs", + "permissions": "Quyền hạn", + "sessions": "Phiên chat", + "workspace": "File workspace", + "team": "Team", + "media": "File media" + }, + "hints": { + "sessions": "Có thể rất lớn", + "media": "Có thể rất lớn", + "team": "Metadata team, thành viên, tasks, links", + "mergeOverwrite": "File cùng tên sẽ bị ghi đè", + "mergeUpsert": "Tài liệu upsert theo path", + "mergeKgUpsert": "Thực thể upsert theo external_id", + "mergeFiles": "File mới thêm vào, cùng tên ghi đè" + }, + "exportButton": "Xuất Agent", + "exporting": "Đang xuất \"{{name}}\"...", + "exportComplete": "Xuất hoàn tất", + "exportFailed": "Xuất thất bại", + "exportAnother": "Xuất agent khác", + "download": "Tải xuống", + "importing": "Đang nhập...", + "importComplete": "Nhập hoàn tất", + "importFailed": "Nhập thất bại", + "importAnother": "Nhập agent khác", + "viewAgent": "Xem Agent", + "tryAgain": "Thử lại", + "cancel": "Huỷ", + "dontClose": "Đừng đóng trang này — đang nhập dữ liệu", + "rolledBack": "Tất cả thay đổi đã được hoàn tác. Hệ thống trở về trạng thái trước.", + "dropHere": "Kéo thả file .tar.gz hoặc click để chọn", + "supportedFormats": "Hỗ trợ: .tar.gz, .agent.json (cũ)", + "invalidFile": "File không hợp lệ — không thể đọc archive", + "importMode": "Chế độ nhập", + "modeNew": "Tạo agent mới", + "modeMerge": "Gộp vào agent có sẵn", + "targetAgent": "Agent đích", + "changeFile": "Đổi file", + "startImport": "Bắt đầu nhập" + }, "toast": { "created": "Đã tạo agent", "createFailed": "Không thể tạo agent", diff --git a/ui/web/src/i18n/locales/vi/import-export.json b/ui/web/src/i18n/locales/vi/import-export.json new file mode 100644 index 00000000..0fe0018f --- /dev/null +++ b/ui/web/src/i18n/locales/vi/import-export.json @@ -0,0 +1,131 @@ +{ + "title": "Nhập & Xuất", + "description": "Xuất agent sang file lưu trữ hoặc nhập từ file.", + "beta": "Beta", + "betaWarning": "Nhập & Xuất đang trong giai đoạn beta. Hãy sao lưu trước khi nhập vào agent hiện có.", + "tabs": { + "teams": "Nhóm", + "agents": "Agent", + "skillsMcp": "Kỹ năng & MCP", + "export": "Xuất", + "import": "Nhập" + }, + "comingSoon": { + "title": "Sắp ra mắt", + "description": "Chức năng này chưa khả dụng." + }, + "export": { + "title": "Xuất Agent", + "agent": "Agent", + "agentPlaceholder": "Chọn agent", + "sections": "Phần dữ liệu", + "preset": "Mẫu nhanh", + "presetsLabel": "Mẫu nhanh", + "infoNote": "Dùng tab Nhóm để xuất agent cùng với nhóm.", + "startExport": "Xuất", + "exporting": "Đang xuất...", + "preview": "Xem trước", + "previewLoading": "Đang tải...", + "download": "Tải xuống", + "done": "Xuất hoàn tất", + "errorTitle": "Xuất thất bại", + "selectAgent": "Chọn một agent để xuất.", + "noAgents": "Không có agent nào." + }, + "import": { + "title": "Nhập Agent", + "mode": "Chế độ", + "modeNew": "Tạo agent mới", + "modeMerge": "Gộp vào agent có sẵn", + "agentKey": "Mã Agent", + "agentKeyPlaceholder": "vd. my-agent", + "agentKeyHint": "Chữ thường, số, dấu gạch ngang. Để trống để dùng mã từ file.", + "targetAgent": "Agent đích", + "targetAgentPlaceholder": "Chọn agent đích", + "file": "File lưu trữ (.tar.gz)", + "filePlaceholder": "Nhấp để chọn hoặc kéo & thả", + "sections": "Phần cần gộp", + "startImport": "Nhập", + "importing": "Đang nhập...", + "done": "Nhập hoàn tất", + "agentCreated": "Đã tạo agent: {{key}}", + "mergedInto": "Đã gộp vào: {{key}}", + "errorTitle": "Nhập thất bại", + "manifestLabel": "Thông tin file", + "agentFrom": "Agent từ file: {{key}}", + "exportedAt": "Xuất lúc: {{date}}" + }, + "sections": { + "config": "Cấu hình", + "configDesc": "Cấu hình agent (model, công cụ, v.v.)", + "context_files": "File ngữ cảnh", + "context_filesDesc": "File ngữ cảnh cấp agent (SOUL.md, IDENTITY.md, v.v.)", + "user_data": "Dữ liệu người dùng", + "user_dataDesc": "Tất cả dữ liệu theo người dùng", + "user_context_files": "File ngữ cảnh người dùng", + "user_context_filesDesc": "File ngữ cảnh theo từng người dùng (USER.md)", + "user_profiles": "Hồ sơ người dùng", + "user_profilesDesc": "Bản ghi hồ sơ workspace người dùng", + "user_overrides": "Ghi đè người dùng", + "user_overridesDesc": "Ghi đè model/provider theo người dùng", + "memory": "Bộ nhớ", + "memoryDesc": "Tất cả tài liệu bộ nhớ (chung + theo người dùng)", + "memory_global": "Bộ nhớ chung", + "memory_globalDesc": "Tài liệu bộ nhớ dùng chung", + "memory_per_user": "Bộ nhớ theo người dùng", + "memory_per_userDesc": "Tài liệu bộ nhớ theo từng người dùng", + "knowledge_graph": "Đồ thị tri thức", + "knowledge_graphDesc": "Thực thể và quan hệ KG", + "cron": "Tác vụ định kỳ", + "cronDesc": "Tác vụ đã lên lịch (nhập ở trạng thái tắt)", + "workspace": "File workspace", + "workspaceDesc": "File trong thư mục workspace của agent", + "sessions": "Phiên chat", + "sessionsDesc": "Lịch sử phiên chat", + "media": "Media", + "mediaDesc": "File media đã tải lên", + "comingSoon": "Sắp ra mắt" + }, + "presets": { + "minimal": "Tối giản", + "standard": "Tiêu chuẩn", + "complete": "Đầy đủ" + }, + "teamExportNote": "Xuất nhóm bao gồm toàn bộ agent thành viên và dữ liệu (file ngữ cảnh, bộ nhớ, đồ thị tri thức, cron, workspace).", + "teamSelectPlaceholder": "Chọn nhóm...", + "teamDropHere": "Kéo thả file nhóm hoặc nhấp để chọn", + "teamDropFormats": "Hỗ trợ file .tar.gz", + "teamDontClose": "Đừng đóng trang — đang nhập", + "teamImportAnother": "Nhập thêm", + "teamTryAgain": "Thử lại", + "teamChangeFile": "Đổi file", + "teamPreview": { + "members": "{{count}} thành viên", + "agents": "{{count}} agent", + "tasks": "{{count}} tác vụ", + "links": "{{count}} liên kết", + "loading": "Đang tải...", + "error": "Không thể tải xem trước" + }, + "skillsMcp": { + "skillsTab": "Kỹ năng", + "mcpTab": "MCP Servers", + "skillsNote": "Chỉ xuất kỹ năng tùy chỉnh (không bao gồm kỹ năng hệ thống).", + "mcpNote": "Cấu hình MCP server được xuất không bao gồm API key, biến môi trường, hay headers. Bạn cần nhập lại secrets sau khi import.", + "customSkills": "{{count}} kỹ năng tùy chỉnh", + "grants": "{{count}} liên kết agent", + "servers": "{{count}} server", + "noSkills": "Không có kỹ năng tùy chỉnh để xuất.", + "noServers": "Không có MCP server để xuất.", + "exportSkills": "Xuất kỹ năng", + "exportMcp": "Xuất MCP Servers", + "importSkills": "Nhập kỹ năng", + "importMcp": "Nhập MCP Servers", + "importedSkills": "Kỹ năng đã nhập", + "skippedSkills": "Kỹ năng bỏ qua (đã tồn tại)", + "importedServers": "Server đã nhập", + "skippedServers": "Server bỏ qua (đã tồn tại)", + "dropSkills": "Kéo thả file kỹ năng hoặc nhấp để chọn", + "dropMcp": "Kéo thả file MCP hoặc nhấp để chọn" + } +} diff --git a/ui/web/src/i18n/locales/vi/sidebar.json b/ui/web/src/i18n/locales/vi/sidebar.json index a34aeeed..b2368c34 100644 --- a/ui/web/src/i18n/locales/vi/sidebar.json +++ b/ui/web/src/i18n/locales/vi/sidebar.json @@ -32,6 +32,7 @@ "providers": "Provider", "config": "Cấu hình", "approvals": "Phê duyệt", + "importExport": "Import & Export", "nodes": "Nodes", "tts": "TTS", "activity": "Hoạt động", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index bd087f83..4795e648 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -753,6 +753,74 @@ "global": "全局" } }, + "transfer": { + "title": "Agent迁移", + "description": "将Agent导出为归档文件或从现有归档导入。", + "tabExport": "导出", + "tabImport": "导入", + "selectAgent": "选择Agent", + "selectAgentPlaceholder": "选择Agent...", + "presets": "预设", + "preset": { + "minimal": "最小", + "standard": "标准", + "complete": "完整" + }, + "sections": { + "config": "Agent配置", + "contextFiles": "上下文文件", + "userData": "用户数据", + "userContextFiles": "用户上下文文件", + "userProfiles": "用户资料", + "userOverrides": "用户覆盖", + "memory": "记忆", + "memoryGlobal": "全局", + "memoryPerUser": "按用户", + "knowledgeGraph": "知识图谱", + "skills": "技能", + "mcp": "MCP授权", + "customTools": "自定义工具", + "cron": "定时任务", + "permissions": "权限", + "sessions": "会话", + "workspace": "工作区文件", + "team": "团队", + "media": "媒体文件" + }, + "hints": { + "sessions": "可能非常大", + "media": "可能非常大", + "team": "团队元数据、成员、任务、链接", + "mergeOverwrite": "同名文件将被覆盖", + "mergeUpsert": "文档按路径更新插入", + "mergeKgUpsert": "实体按external_id更新插入", + "mergeFiles": "新文件添加,同名覆盖" + }, + "exportButton": "导出Agent", + "exporting": "正在导出\"{{name}}\"...", + "exportComplete": "导出完成", + "exportFailed": "导出失败", + "exportAnother": "导出其他", + "download": "下载", + "importing": "正在导入...", + "importComplete": "导入完成", + "importFailed": "导入失败", + "importAnother": "导入其他", + "viewAgent": "查看Agent", + "tryAgain": "重试", + "cancel": "取消", + "dontClose": "请勿关闭此页面——正在导入中", + "rolledBack": "所有更改已回滚。系统已恢复到之前的状态。", + "dropHere": "拖放.tar.gz文件到此处或点击浏览", + "supportedFormats": "支持:.tar.gz、.agent.json(旧版)", + "invalidFile": "无效文件——无法解析归档", + "importMode": "导入模式", + "modeNew": "创建新Agent", + "modeMerge": "合并到现有Agent", + "targetAgent": "目标Agent", + "changeFile": "更换文件", + "startImport": "开始导入" + }, "toast": { "created": "代理已创建", "createFailed": "创建代理失败", diff --git a/ui/web/src/i18n/locales/zh/import-export.json b/ui/web/src/i18n/locales/zh/import-export.json new file mode 100644 index 00000000..47470fef --- /dev/null +++ b/ui/web/src/i18n/locales/zh/import-export.json @@ -0,0 +1,131 @@ +{ + "title": "导入与导出", + "description": "将 Agent 导出为便携档案包,或从档案包导入。", + "beta": "测试版", + "betaWarning": "导入与导出功能仍在测试中。导入到已有 Agent 前请务必备份。", + "tabs": { + "teams": "团队", + "agents": "Agent", + "skillsMcp": "技能与 MCP", + "export": "导出", + "import": "导入" + }, + "comingSoon": { + "title": "即将推出", + "description": "此功能暂未开放。" + }, + "export": { + "title": "导出 Agent", + "agent": "Agent", + "agentPlaceholder": "选择 Agent", + "sections": "导出内容", + "preset": "预设", + "presetsLabel": "预设方案", + "infoNote": "如需将 Agent 与团队一起导出,请使用\"团队\"标签页。", + "startExport": "导出", + "exporting": "正在导出...", + "preview": "预览", + "previewLoading": "加载中...", + "download": "下载", + "done": "导出完成", + "errorTitle": "导出失败", + "selectAgent": "请选择要导出的 Agent。", + "noAgents": "暂无可用 Agent。" + }, + "import": { + "title": "导入 Agent", + "mode": "模式", + "modeNew": "创建新 Agent", + "modeMerge": "合并到已有 Agent", + "agentKey": "Agent 标识", + "agentKeyPlaceholder": "如 my-agent", + "agentKeyHint": "小写字母、数字、连字符。留空将使用档案包中的标识。", + "targetAgent": "目标 Agent", + "targetAgentPlaceholder": "选择目标 Agent", + "file": "档案文件(.tar.gz)", + "filePlaceholder": "点击选择或拖放文件", + "sections": "合并内容", + "startImport": "导入", + "importing": "正在导入...", + "done": "导入完成", + "agentCreated": "已创建 Agent:{{key}}", + "mergedInto": "已合并到:{{key}}", + "errorTitle": "导入失败", + "manifestLabel": "档案信息", + "agentFrom": "档案中的 Agent:{{key}}", + "exportedAt": "导出时间:{{date}}" + }, + "sections": { + "config": "配置", + "configDesc": "Agent 配置(模型、工具等)", + "context_files": "上下文文件", + "context_filesDesc": "Agent 级上下文文件(SOUL.md、IDENTITY.md 等)", + "user_data": "用户数据", + "user_dataDesc": "所有用户级数据(上下文文件、配置文件、覆盖设置)", + "user_context_files": "用户上下文文件", + "user_context_filesDesc": "每用户上下文文件(USER.md)", + "user_profiles": "用户配置文件", + "user_profilesDesc": "用户工作区配置记录", + "user_overrides": "用户覆盖设置", + "user_overridesDesc": "每用户模型/提供商覆盖设置", + "memory": "记忆", + "memoryDesc": "所有记忆文档(全局 + 每用户)", + "memory_global": "全局记忆", + "memory_globalDesc": "共享记忆文档", + "memory_per_user": "用户记忆", + "memory_per_userDesc": "每用户记忆文档", + "knowledge_graph": "知识图谱", + "knowledge_graphDesc": "知识图谱实体和关系", + "cron": "定时任务", + "cronDesc": "计划任务(以禁用状态导入)", + "workspace": "工作区文件", + "workspaceDesc": "Agent 工作区目录中的文件", + "sessions": "会话记录", + "sessionsDesc": "聊天会话与消息", + "media": "媒体文件", + "mediaDesc": "已上传的媒体文件", + "comingSoon": "即将推出" + }, + "presets": { + "minimal": "精简", + "standard": "标准", + "complete": "完整" + }, + "teamExportNote": "团队导出包含所有成员 Agent 及其数据(上下文文件、记忆、知识图谱、定时任务、工作区)。", + "teamSelectPlaceholder": "选择团队...", + "teamDropHere": "拖放团队归档文件或点击浏览", + "teamDropFormats": "支持 .tar.gz 文件", + "teamDontClose": "请勿关闭页面 — 正在导入", + "teamImportAnother": "再次导入", + "teamTryAgain": "重试", + "teamChangeFile": "更换文件", + "teamPreview": { + "members": "{{count}} 名成员", + "agents": "{{count}} 个 Agent", + "tasks": "{{count}} 个任务", + "links": "{{count}} 个链接", + "loading": "加载中...", + "error": "预览加载失败" + }, + "skillsMcp": { + "skillsTab": "技能", + "mcpTab": "MCP 服务器", + "skillsNote": "仅导出自定义技能(不包含系统技能)。", + "mcpNote": "MCP 服务器配置导出时不包含 API 密钥、环境变量和请求头。导入后需重新填写密钥信息。", + "customSkills": "{{count}} 个自定义技能", + "grants": "{{count}} 个 Agent 授权", + "servers": "{{count}} 个服务器", + "noSkills": "没有自定义技能可导出。", + "noServers": "没有 MCP 服务器可导出。", + "exportSkills": "导出技能", + "exportMcp": "导出 MCP 服务器", + "importSkills": "导入技能", + "importMcp": "导入 MCP 服务器", + "importedSkills": "已导入技能", + "skippedSkills": "跳过的技能(已存在)", + "importedServers": "已导入服务器", + "skippedServers": "跳过的服务器(已存在)", + "dropSkills": "拖放技能归档文件或点击浏览", + "dropMcp": "拖放 MCP 归档文件或点击浏览" + } +} diff --git a/ui/web/src/i18n/locales/zh/sidebar.json b/ui/web/src/i18n/locales/zh/sidebar.json index d3f447a2..45b45947 100644 --- a/ui/web/src/i18n/locales/zh/sidebar.json +++ b/ui/web/src/i18n/locales/zh/sidebar.json @@ -12,6 +12,7 @@ "agentTeams": "Agent Team", "agents": "Agent", "approvals": "审批", + "importExport": "Import & Export", "builtinTools": "内置工具", "channels": "Channel", "contacts": "联系人", diff --git a/ui/web/src/lib/constants.ts b/ui/web/src/lib/constants.ts index 720cf398..81f742a0 100644 --- a/ui/web/src/lib/constants.ts +++ b/ui/web/src/lib/constants.ts @@ -6,6 +6,7 @@ export const ROUTES = { AGENTS: "/agents", AGENT_DETAIL: "/agents/:id", AGENT_CODEX_POOL: "/agents/:id/codex-pool", + IMPORT_EXPORT: "/import-export", SESSIONS: "/sessions", SESSION_DETAIL: "/sessions/:key", SKILLS: "/skills", diff --git a/ui/web/src/pages/agents/agents-page.tsx b/ui/web/src/pages/agents/agents-page.tsx index 12a162f9..281d0de7 100644 --- a/ui/web/src/pages/agents/agents-page.tsx +++ b/ui/web/src/pages/agents/agents-page.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from "react"; import { useParams, useNavigate } from "react-router"; -import { Plus, Bot, LayoutGrid, List } from "lucide-react"; +import { Plus, Bot, LayoutGrid, List, ArrowLeftRight } from "lucide-react"; import { useTranslation } from "react-i18next"; import { PageHeader } from "@/components/shared/page-header"; import { EmptyState } from "@/components/shared/empty-state"; @@ -96,9 +96,14 @@ export function AgentsPage() { title={t("title")} description={t("description")} actions={ - +
+ + +
} /> diff --git a/ui/web/src/pages/import-export/agent-export-panel.tsx b/ui/web/src/pages/import-export/agent-export-panel.tsx new file mode 100644 index 00000000..6cfaee31 --- /dev/null +++ b/ui/web/src/pages/import-export/agent-export-panel.tsx @@ -0,0 +1,144 @@ +import { useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Download, Package, Info } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Combobox } from "@/components/ui/combobox"; +import { OperationProgress } from "@/components/shared/operation-progress"; +import { useAgents } from "@/pages/agents/hooks/use-agents"; +import { SectionPicker, PRESETS, type SectionDef } from "./section-picker"; +import { useExportPreview, useExport } from "./hooks/use-agent-export"; + +const PRESET_KEYS = ["minimal", "standard", "complete"] as const; + +export function AgentExportPanel() { + const { t } = useTranslation("import-export"); + const { agents } = useAgents(); + const [agentId, setAgentId] = useState(""); + const [selected, setSelected] = useState>(new Set(PRESETS.standard)); + + const agent = agents.find((a) => a.id === agentId); + const { data: preview } = useExportPreview(agentId || null); + const exp = useExport(); + + const agentOptions = useMemo( + () => agents.map((a) => ({ value: a.id, label: a.display_name || a.agent_key })), + [agents], + ); + + const sections: SectionDef[] = useMemo(() => { + const p = preview; + return [ + { id: "config", labelKey: "sections.config", required: true }, + { id: "context_files", labelKey: "sections.context_files", count: p?.context_files }, + { + id: "user_data", labelKey: "sections.user_data", count: p?.user_context_files_users, + countLabel: p ? `${p.user_context_files_users} users` : undefined, + children: [ + { id: "user_context_files", labelKey: "sections.user_context_files", count: p?.user_context_files_users }, + { id: "user_profiles", labelKey: "sections.user_profiles", count: p?.user_profiles }, + { id: "user_overrides", labelKey: "sections.user_overrides", count: p?.user_overrides }, + ], + }, + { + id: "memory", labelKey: "sections.memory", + count: p ? p.memory_global + p.memory_per_user : undefined, + countLabel: p ? `${p.memory_global + p.memory_per_user} docs` : undefined, + children: [ + { id: "memory_global", labelKey: "sections.memory_global", count: p?.memory_global }, + { id: "memory_per_user", labelKey: "sections.memory_per_user", count: p?.memory_per_user }, + ], + }, + { + id: "knowledge_graph", labelKey: "sections.knowledge_graph", + countLabel: p ? `${p.kg_entities.toLocaleString()} ent / ${p.kg_relations.toLocaleString()} rel` : undefined, + }, + { id: "cron", labelKey: "sections.cron", count: p?.cron_jobs }, + { id: "workspace", labelKey: "sections.workspace", count: p?.workspace_files }, + ]; + }, [preview]); + + const handlePreset = (preset: string) => setSelected(new Set(PRESETS[preset] ?? [])); + + const handleExport = () => { + if (!agent) return; + const secs = Array.from(selected).filter((s) => !s.startsWith("memory_") && !s.startsWith("user_")); + exp.startExport(agent.id, secs); + }; + + // Idle state + if (exp.status === "idle") { + return ( +
+
+ +

{t("export.infoNote")}

+
+ +
+ + +
+ + {agentId && ( + <> +
+ {t("export.presetsLabel")}: + {PRESET_KEYS.map((p) => ( + + ))} +
+ + + +
+ + {agent && (agent.display_name || agent.agent_key)} + + +
+ + )} +
+ ); + } + + // Running / complete / error + return ( +
+

+ {exp.status === "complete" ? t("export.done") : exp.status === "error" ? t("export.errorTitle") : t("export.exporting")} +

+ + {exp.status === "error" && exp.error && ( +

{exp.error.detail}

+ )} +
+ {exp.status === "running" && } + {exp.status === "complete" && exp.downloadReady && ( + <> + + + + )} + {exp.status === "error" && } +
+
+ ); +} + +function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) return false; + for (const v of a) if (!b.has(v)) return false; + return true; +} diff --git a/ui/web/src/pages/import-export/agent-import-panel.tsx b/ui/web/src/pages/import-export/agent-import-panel.tsx new file mode 100644 index 00000000..bc54a968 --- /dev/null +++ b/ui/web/src/pages/import-export/agent-import-panel.tsx @@ -0,0 +1,203 @@ +import { useState, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router"; +import { Upload, FileArchive, AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Combobox } from "@/components/ui/combobox"; +import { OperationProgress } from "@/components/shared/operation-progress"; +import { useAgents } from "@/pages/agents/hooks/use-agents"; +import { SectionPicker, type SectionDef } from "./section-picker"; +import { useImport, type ImportMode } from "./hooks/use-agent-import"; +import { slugify, isValidSlug } from "@/lib/slug"; + +const MERGE_SECTIONS: SectionDef[] = [ + { id: "context_files", labelKey: "sections.context_files" }, + { id: "memory", labelKey: "sections.memory" }, + { id: "knowledge_graph", labelKey: "sections.knowledge_graph" }, + { id: "cron", labelKey: "sections.cron" }, + { id: "user_profiles", labelKey: "sections.user_profiles" }, + { id: "user_overrides", labelKey: "sections.user_overrides" }, + { id: "workspace", labelKey: "sections.workspace" }, +]; + +export function AgentImportPanel() { + const { t } = useTranslation("import-export"); + const navigate = useNavigate(); + const { agents } = useAgents(); + const fileRef = useRef(null); + const [file, setFile] = useState(null); + const [dragging, setDragging] = useState(false); + const [mode, setMode] = useState("new"); + const [agentKey, setAgentKey] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [keyTouched, setKeyTouched] = useState(false); + const [mergeTarget, setMergeTarget] = useState(""); + const [mergeSelected, setMergeSelected] = useState>( + new Set(["context_files", "memory", "knowledge_graph"]), + ); + const [parseError, setParseError] = useState(""); + const imp = useImport(); + + const handleFile = useCallback(async (f: File) => { + setFile(f); + setParseError(""); + const m = await imp.parseFile(f); + if (!m) { setParseError(t("import.errorTitle")); return; } + setAgentKey(m.agent_key || ""); + setDisplayName(""); + setKeyTouched(false); + if (!m.sections.agent_config) setMode("merge"); + }, [imp, t]); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); setDragging(false); + const f = e.dataTransfer.files[0]; + if (f) handleFile(f); + }, [handleFile]); + + const handleNameChange = (val: string) => { + setDisplayName(val); + if (!keyTouched) setAgentKey(slugify(val)); + }; + + const handleSubmit = () => { + if (!file) return; + if (mode === "merge") { + if (!mergeTarget) return; + imp.startMerge(file, mergeTarget, Array.from(mergeSelected)); + } else { + imp.startImport(file, { agent_key: agentKey || undefined, display_name: displayName || undefined }); + } + }; + + const agentOptions = agents.map((a) => ({ value: a.id, label: a.display_name || a.agent_key })); + + // Running / Complete / Error + if (imp.status !== "idle") { + return ( +
+

+ {imp.status === "complete" ? t("import.done") : imp.status === "error" ? t("import.errorTitle") : t("import.importing")} +

+ + {imp.status === "error" && imp.error && ( +
+

{imp.error.detail}

+
+ )} +
+ {imp.status === "running" && ( + <> +

+ + {t("betaWarning")} +

+ + + )} + {imp.status === "complete" && imp.result && ( + <> + + {imp.result.agent_id && ( + + )} + + )} + {imp.status === "error" && ( + + )} +
+
+ ); + } + + // Idle + return ( +
+ {!file && ( +
{ e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + onClick={() => fileRef.current?.click()} + > + +

{t("import.filePlaceholder")}

+

{t("import.file")}

+ { const f = e.target.files?.[0]; if (f) handleFile(f); }} /> +
+ )} + {parseError &&

{parseError}

} + + {file && imp.manifest && ( + <> +
+
+ + {file.name} + {(file.size / 1024).toFixed(0)} KB +
+ {imp.manifest.agent_key && ( +
+ Agent: {imp.manifest.agent_key} · v{imp.manifest.version} +
+ )} +
+ +
+ + +
+ + {mode === "new" && ( + <> +
+ + handleNameChange(e.target.value)} + placeholder={t("import.agentKeyPlaceholder")} className="mt-1 text-base md:text-sm" /> +
+
+ + { setAgentKey(e.target.value); setKeyTouched(true); }} + placeholder={t("import.agentKeyPlaceholder")} className="mt-1 font-mono text-base md:text-sm" /> +
+ + )} + + {mode === "merge" && ( + <> +
+ + +
+ + + )} + +
+ + +
+ + )} +
+ ); +} diff --git a/ui/web/src/pages/import-export/capabilities-export-panel.tsx b/ui/web/src/pages/import-export/capabilities-export-panel.tsx new file mode 100644 index 00000000..9dd98851 --- /dev/null +++ b/ui/web/src/pages/import-export/capabilities-export-panel.tsx @@ -0,0 +1,188 @@ +import { useTranslation } from "react-i18next"; +import { Download, Package, Info } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { OperationProgress } from "@/components/shared/operation-progress"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { + useSkillsExportPreview, + useMcpExportPreview, + useSkillsExport, + useMcpExport, +} from "./hooks/use-capabilities-export"; + +function SkillsExportTab() { + const { t } = useTranslation("import-export"); + const { data: preview, isLoading } = useSkillsExportPreview(); + const exp = useSkillsExport(); + + if (exp.status !== "idle") { + return ( +
+

+ {exp.status === "complete" + ? t("export.done") + : exp.status === "error" + ? t("export.errorTitle") + : t("export.exporting")} +

+ + {exp.status === "error" && exp.error && ( +

{exp.error.detail}

+ )} +
+ {exp.status === "running" && ( + + )} + {exp.status === "complete" && exp.downloadReady && ( + <> + + + + )} + {exp.status === "error" && ( + + )} +
+
+ ); + } + + return ( +
+
+ +

{t("skillsMcp.skillsNote")}

+
+ + {isLoading && ( +

{t("export.previewLoading")}

+ )} + + {preview && ( +
+
+ {t("skillsMcp.customSkills", { count: preview.custom_skills })} + {t("skillsMcp.grants", { count: preview.total_grants })} +
+
+ )} + + {preview?.custom_skills === 0 && ( +

{t("skillsMcp.noSkills")}

+ )} + +
+ +
+
+ ); +} + +function McpExportTab() { + const { t } = useTranslation("import-export"); + const { data: preview, isLoading } = useMcpExportPreview(); + const exp = useMcpExport(); + + if (exp.status !== "idle") { + return ( +
+

+ {exp.status === "complete" + ? t("export.done") + : exp.status === "error" + ? t("export.errorTitle") + : t("export.exporting")} +

+ + {exp.status === "error" && exp.error && ( +

{exp.error.detail}

+ )} +
+ {exp.status === "running" && ( + + )} + {exp.status === "complete" && exp.downloadReady && ( + <> + + + + )} + {exp.status === "error" && ( + + )} +
+
+ ); + } + + return ( +
+
+ +

{t("skillsMcp.mcpNote")}

+
+ + {isLoading && ( +

{t("export.previewLoading")}

+ )} + + {preview && ( +
+
+ {t("skillsMcp.servers", { count: preview.servers })} + {t("skillsMcp.grants", { count: preview.agent_grants })} +
+
+ )} + + {preview?.servers === 0 && ( +

{t("skillsMcp.noServers")}

+ )} + +
+ +
+
+ ); +} + +export function CapabilitiesExportPanel() { + const { t } = useTranslation("import-export"); + + return ( + + + {t("skillsMcp.skillsTab")} + {t("skillsMcp.mcpTab")} + + + + + + + + + ); +} diff --git a/ui/web/src/pages/import-export/capabilities-import-panel.tsx b/ui/web/src/pages/import-export/capabilities-import-panel.tsx new file mode 100644 index 00000000..a3aff1a0 --- /dev/null +++ b/ui/web/src/pages/import-export/capabilities-import-panel.tsx @@ -0,0 +1,261 @@ +import { useState, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Upload, FileArchive, Info, CheckCircle2, SkipForward } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { OperationProgress, type ProgressStep } from "@/components/shared/operation-progress"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import type { SseCompleteEvent } from "@/hooks/use-sse-progress"; +import { useSkillsImport, useMcpImport } from "./hooks/use-capabilities-import"; + +interface ImportSummaryEntry { + label: string; + value: number; + type?: "created" | "skipped"; +} + +interface DropZoneImportTabProps { + dropLabel: string; + dropFormats: string; + startLabel: string; + doneLabel: string; + errorLabel: string; + importingLabel: string; + infoNote?: string; + onImport: (file: File) => void; + status: string; + steps: ProgressStep[]; + elapsed: number; + error: { detail: string } | null; + result: SseCompleteEvent | null; + summaryEntries?: (result: SseCompleteEvent) => ImportSummaryEntry[]; + reset: () => void; + cancel: () => void; +} + +function DropZoneImportTab({ + dropLabel, + dropFormats, + startLabel, + doneLabel, + errorLabel, + importingLabel, + infoNote, + onImport, + status, + steps, + elapsed, + error, + result, + summaryEntries, + reset, + cancel, +}: DropZoneImportTabProps) { + const { t } = useTranslation("import-export"); + const fileRef = useRef(null); + const [file, setFile] = useState(null); + const [dragging, setDragging] = useState(false); + + const handleFile = useCallback((f: File) => setFile(f), []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + const f = e.dataTransfer.files[0]; + if (f) handleFile(f); + }, + [handleFile], + ); + + if (status !== "idle") { + const entries = status === "complete" && result && summaryEntries ? summaryEntries(result) : []; + return ( +
+

+ {status === "complete" ? doneLabel : status === "error" ? errorLabel : importingLabel} +

+ {status === "running" && } + {status === "complete" && entries.length > 0 && ( +
+ {entries.map((e, i) => ( +
+ {e.type === "skipped" + ? + : } + {e.label} + {e.value} +
+ ))} +
+ {elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed / 60)}m ${elapsed % 60}s`} +
+
+ )} + {status === "complete" && entries.length === 0 && ( + + )} + {status === "error" && error && ( +
+

{error.detail}

+
+ )} +
+ {status === "running" && ( + + )} + {(status === "complete" || status === "error") && ( + + )} +
+
+ ); + } + + return ( +
+ {infoNote && ( +
+ +

{infoNote}

+
+ )} + + {!file && ( +
{ e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + onClick={() => fileRef.current?.click()} + > + +

{dropLabel}

+

{dropFormats}

+ { const f = e.target.files?.[0]; if (f) handleFile(f); }} + /> +
+ )} + + {file && ( + <> +
+
+ + {file.name} + + {(file.size / 1024).toFixed(0)} KB + +
+
+ +
+ + +
+ + )} +
+ ); +} + +function SkillsImportTab() { + const { t } = useTranslation("import-export"); + const imp = useSkillsImport(); + + return ( + [ + { label: t("skillsMcp.importedSkills"), value: (r.skills_imported as number) ?? 0 }, + ...(r.skills_skipped ? [{ label: t("skillsMcp.skippedSkills"), value: r.skills_skipped as number, type: "skipped" as const }] : []), + { label: t("skillsMcp.grants"), value: (r.grants_applied as number) ?? 0 }, + ]} + reset={imp.reset} + cancel={imp.cancel} + /> + ); +} + +function McpImportTab() { + const { t } = useTranslation("import-export"); + const imp = useMcpImport(); + + return ( + [ + { label: t("skillsMcp.importedServers"), value: (r.servers_imported as number) ?? 0 }, + ...(r.servers_skipped ? [{ label: t("skillsMcp.skippedServers"), value: r.servers_skipped as number, type: "skipped" as const }] : []), + { label: t("skillsMcp.grants"), value: (r.grants_applied as number) ?? 0 }, + ]} + reset={imp.reset} + cancel={imp.cancel} + /> + ); +} + +export function CapabilitiesImportPanel() { + const { t } = useTranslation("import-export"); + + return ( + + + {t("skillsMcp.skillsTab")} + {t("skillsMcp.mcpTab")} + + + + + + + + + ); +} diff --git a/ui/web/src/pages/import-export/hooks/use-agent-export.ts b/ui/web/src/pages/import-export/hooks/use-agent-export.ts new file mode 100644 index 00000000..e144174c --- /dev/null +++ b/ui/web/src/pages/import-export/hooks/use-agent-export.ts @@ -0,0 +1,75 @@ +import { useState, useCallback, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { toast } from "@/stores/use-toast-store"; +import { useSseProgress, type UseSseProgressReturn } from "@/hooks/use-sse-progress"; + +export interface ExportPreview { + context_files: number; + user_context_files_users: number; + memory_global: number; + memory_per_user: number; + kg_entities: number; + kg_relations: number; + user_profiles: number; + user_overrides: number; + cron_jobs: number; + workspace_files: number; +} + +export function useExportPreview(agentId: string | null) { + const http = useHttp(); + return useQuery({ + queryKey: ["export-preview", agentId], + enabled: !!agentId, + queryFn: () => http.get(`/v1/agents/${agentId}/export/preview`), + staleTime: 30_000, + }); +} + +export function useExport(): UseSseProgressReturn & { + startExport: (agentId: string, sections: string[]) => void; + downloadReady: boolean; + download: () => void; +} { + const http = useHttp(); + const [downloadUrl, setDownloadUrl] = useState(null); + const [downloadName, setDownloadName] = useState(""); + + const authHeaders = useCallback(() => http.getAuthHeaders(), [http]); + const sse = useSseProgress(authHeaders); + + useEffect(() => { + if (sse.result?.download_url && !downloadUrl) { + setDownloadUrl(sse.result.download_url); + setDownloadName(sse.result.file_name ?? "export.tar.gz"); + } + }, [sse.result, downloadUrl]); + + const startExport = useCallback( + (agentId: string, sections: string[]) => { + setDownloadUrl(null); + setDownloadName(""); + const params = new URLSearchParams({ sections: sections.join(","), stream: "true" }); + const url = `${window.location.origin}/v1/agents/${agentId}/export?${params}`; + sse.startGet(url); + }, + [sse], + ); + + const download = useCallback(async () => { + if (!downloadUrl) return; + try { + const blob = await http.downloadBlob(downloadUrl); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = downloadName; + a.click(); + URL.revokeObjectURL(a.href); + } catch { + toast.error("Download failed"); + } + }, [downloadUrl, downloadName, http]); + + return { ...sse, startExport, downloadReady: !!downloadUrl, download }; +} diff --git a/ui/web/src/pages/import-export/hooks/use-agent-import.ts b/ui/web/src/pages/import-export/hooks/use-agent-import.ts new file mode 100644 index 00000000..8e374b7f --- /dev/null +++ b/ui/web/src/pages/import-export/hooks/use-agent-import.ts @@ -0,0 +1,102 @@ +import { useState, useCallback } from "react"; +import { useHttp } from "@/hooks/use-ws"; +import { useSseProgress, type UseSseProgressReturn } from "@/hooks/use-sse-progress"; + +export type ImportMode = "new" | "merge"; + +export interface ImportManifest { + version: number; + format: string; + exported_at: string; + agent_key: string; + sections: Record; +} + +export function useImport(): UseSseProgressReturn & { + manifest: ImportManifest | null; + parseFile: (file: File) => Promise; + startImport: (file: File, overrides?: { agent_key?: string; display_name?: string }) => void; + startMerge: (file: File, agentId: string, sections: string[]) => void; + clearFile: () => void; +} { + const http = useHttp(); + const [manifest, setManifest] = useState(null); + + const authHeaders = useCallback(() => http.getAuthHeaders(), [http]); + const sse = useSseProgress(authHeaders); + + const parseFile = useCallback(async (file: File): Promise => { + if (file.name.endsWith(".agent.json") || file.name.endsWith(".json")) { + try { + const text = await file.text(); + const json = JSON.parse(text); + const m: ImportManifest = { + version: json.version ?? 1, + format: json.format ?? "legacy-json", + exported_at: json.exported_at ?? "", + agent_key: json.agent?.agent_key ?? "", + sections: { + agent_config: !!json.agent, + context_files: (json.context_files?.length ?? 0) > 0, + memory: (json.memories?.length ?? 0) > 0, + knowledge_graph: !!(json.knowledge_graph?.entities?.length), + }, + }; + setManifest(m); + return m; + } catch { + return null; + } + } + + try { + const form = new FormData(); + form.append("file", file); + const res = await fetch( + `${window.location.origin}/v1/agents/import/preview`, + { method: "POST", headers: authHeaders(), body: form }, + ); + if (!res.ok) return null; + const m = (await res.json()) as ImportManifest; + setManifest(m); + return m; + } catch { + return null; + } + }, [authHeaders]); + + const startImport = useCallback( + (file: File, overrides?: { agent_key?: string; display_name?: string }) => { + const form = new FormData(); + form.append("file", file); + if (overrides?.agent_key) form.append("agent_key", overrides.agent_key); + if (overrides?.display_name) form.append("display_name", overrides.display_name); + + const url = `${window.location.origin}/v1/agents/import?stream=true`; + sse.startPost(url, form); + }, + [sse], + ); + + const startMerge = useCallback( + (file: File, agentId: string, sections: string[]) => { + const form = new FormData(); + form.append("file", file); + + const params = new URLSearchParams({ + include: sections.join(","), + stream: "true", + }); + const url = `${window.location.origin}/v1/agents/${agentId}/import?${params}`; + sse.startPost(url, form); + }, + [sse], + ); + + const clearFile = useCallback(() => { + setManifest(null); + sse.reset(); + }, [sse]); + + return { ...sse, manifest, parseFile, startImport, startMerge, clearFile }; +} diff --git a/ui/web/src/pages/import-export/hooks/use-capabilities-export.ts b/ui/web/src/pages/import-export/hooks/use-capabilities-export.ts new file mode 100644 index 00000000..5b1abe93 --- /dev/null +++ b/ui/web/src/pages/import-export/hooks/use-capabilities-export.ts @@ -0,0 +1,98 @@ +import { useState, useCallback, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { toast } from "@/stores/use-toast-store"; +import { useSseProgress, type UseSseProgressReturn } from "@/hooks/use-sse-progress"; + +export interface SkillsExportPreview { + custom_skills: number; + total_grants: number; +} + +export interface McpExportPreview { + servers: number; + agent_grants: number; +} + +export function useSkillsExportPreview() { + const http = useHttp(); + return useQuery({ + queryKey: ["skills-export-preview"], + queryFn: () => http.get("/v1/skills/export/preview"), + staleTime: 30_000, + }); +} + +export function useMcpExportPreview() { + const http = useHttp(); + return useQuery({ + queryKey: ["mcp-export-preview"], + queryFn: () => http.get("/v1/mcp/export/preview"), + staleTime: 30_000, + }); +} + +function useCapabilityExport(defaultFileName: string): UseSseProgressReturn & { + startExport: (url: string) => void; + downloadReady: boolean; + download: () => void; +} { + const http = useHttp(); + const [downloadUrl, setDownloadUrl] = useState(null); + const [downloadName, setDownloadName] = useState(""); + + const authHeaders = useCallback(() => http.getAuthHeaders(), [http]); + const sse = useSseProgress(authHeaders); + + useEffect(() => { + if (sse.result?.download_url && !downloadUrl) { + setDownloadUrl(sse.result.download_url); + setDownloadName(sse.result.file_name ?? defaultFileName); + } + }, [sse.result, downloadUrl, defaultFileName]); + + const startExport = useCallback( + (exportUrl: string) => { + setDownloadUrl(null); + setDownloadName(""); + sse.startGet(exportUrl); + }, + [sse], + ); + + const download = useCallback(async () => { + if (!downloadUrl) return; + try { + const blob = await http.downloadBlob(downloadUrl); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = downloadName; + a.click(); + URL.revokeObjectURL(a.href); + } catch { + toast.error("Download failed"); + } + }, [downloadUrl, downloadName, http]); + + return { ...sse, startExport, downloadReady: !!downloadUrl, download }; +} + +export function useSkillsExport() { + const exp = useCapabilityExport("skills-export.tar.gz"); + return { + ...exp, + startSkillsExport: () => { + exp.startExport(`${window.location.origin}/v1/skills/export?stream=true`); + }, + }; +} + +export function useMcpExport() { + const exp = useCapabilityExport("mcp-export.tar.gz"); + return { + ...exp, + startMcpExport: () => { + exp.startExport(`${window.location.origin}/v1/mcp/export?stream=true`); + }, + }; +} diff --git a/ui/web/src/pages/import-export/hooks/use-capabilities-import.ts b/ui/web/src/pages/import-export/hooks/use-capabilities-import.ts new file mode 100644 index 00000000..5d28657e --- /dev/null +++ b/ui/web/src/pages/import-export/hooks/use-capabilities-import.ts @@ -0,0 +1,43 @@ +import { useCallback } from "react"; +import { useHttp } from "@/hooks/use-ws"; +import { useSseProgress, type UseSseProgressReturn } from "@/hooks/use-sse-progress"; + +export function useSkillsImport(): UseSseProgressReturn & { + startImport: (file: File) => void; +} { + const http = useHttp(); + const authHeaders = useCallback(() => http.getAuthHeaders(), [http]); + const sse = useSseProgress(authHeaders); + + const startImport = useCallback( + (file: File) => { + const form = new FormData(); + form.append("file", file); + const url = `${window.location.origin}/v1/skills/import?stream=true`; + sse.startPost(url, form); + }, + [sse], + ); + + return { ...sse, startImport }; +} + +export function useMcpImport(): UseSseProgressReturn & { + startImport: (file: File) => void; +} { + const http = useHttp(); + const authHeaders = useCallback(() => http.getAuthHeaders(), [http]); + const sse = useSseProgress(authHeaders); + + const startImport = useCallback( + (file: File) => { + const form = new FormData(); + form.append("file", file); + const url = `${window.location.origin}/v1/mcp/import?stream=true`; + sse.startPost(url, form); + }, + [sse], + ); + + return { ...sse, startImport }; +} diff --git a/ui/web/src/pages/import-export/hooks/use-team-export.ts b/ui/web/src/pages/import-export/hooks/use-team-export.ts new file mode 100644 index 00000000..21ad3cd2 --- /dev/null +++ b/ui/web/src/pages/import-export/hooks/use-team-export.ts @@ -0,0 +1,70 @@ +import { useState, useCallback, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { toast } from "@/stores/use-toast-store"; +import { useSseProgress, type UseSseProgressReturn } from "@/hooks/use-sse-progress"; + +export interface TeamExportPreview { + team_name: string; + team_id: string; + tasks: number; + members: number; + agent_links: number; + agent_count: number; +} + +export function useTeamExportPreview(teamId: string | null) { + const http = useHttp(); + return useQuery({ + queryKey: ["team-export-preview", teamId], + enabled: !!teamId, + queryFn: () => http.get(`/v1/teams/${teamId}/export/preview`), + staleTime: 30_000, + }); +} + +export function useTeamExport(): UseSseProgressReturn & { + startExport: (teamId: string) => void; + downloadReady: boolean; + download: () => void; +} { + const http = useHttp(); + const [downloadUrl, setDownloadUrl] = useState(null); + const [downloadName, setDownloadName] = useState(""); + + const authHeaders = useCallback(() => http.getAuthHeaders(), [http]); + const sse = useSseProgress(authHeaders); + + useEffect(() => { + if (sse.result?.download_url && !downloadUrl) { + setDownloadUrl(sse.result.download_url); + setDownloadName(sse.result.file_name ?? "team-export.tar.gz"); + } + }, [sse.result, downloadUrl]); + + const startExport = useCallback( + (teamId: string) => { + setDownloadUrl(null); + setDownloadName(""); + const url = `${window.location.origin}/v1/teams/${teamId}/export?stream=true`; + sse.startGet(url); + }, + [sse], + ); + + const download = useCallback(async () => { + if (!downloadUrl) return; + try { + const blob = await http.downloadBlob(downloadUrl); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = downloadName; + a.click(); + URL.revokeObjectURL(a.href); + } catch { + toast.error("Download failed"); + } + }, [downloadUrl, downloadName, http]); + + return { ...sse, startExport, downloadReady: !!downloadUrl, download }; +} diff --git a/ui/web/src/pages/import-export/hooks/use-team-import.ts b/ui/web/src/pages/import-export/hooks/use-team-import.ts new file mode 100644 index 00000000..bed7c5f6 --- /dev/null +++ b/ui/web/src/pages/import-export/hooks/use-team-import.ts @@ -0,0 +1,23 @@ +import { useCallback } from "react"; +import { useHttp } from "@/hooks/use-ws"; +import { useSseProgress, type UseSseProgressReturn } from "@/hooks/use-sse-progress"; + +export function useTeamImport(): UseSseProgressReturn & { + startImport: (file: File) => void; +} { + const http = useHttp(); + const authHeaders = useCallback(() => http.getAuthHeaders(), [http]); + const sse = useSseProgress(authHeaders); + + const startImport = useCallback( + (file: File) => { + const form = new FormData(); + form.append("file", file); + const url = `${window.location.origin}/v1/teams/import?stream=true`; + sse.startPost(url, form); + }, + [sse], + ); + + return { ...sse, startImport }; +} diff --git a/ui/web/src/pages/import-export/import-export-page.tsx b/ui/web/src/pages/import-export/import-export-page.tsx new file mode 100644 index 00000000..7d91d19c --- /dev/null +++ b/ui/web/src/pages/import-export/import-export-page.tsx @@ -0,0 +1,111 @@ +import { useSearchParams } from "react-router"; +import { useTranslation } from "react-i18next"; +import { AlertTriangle } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { AgentExportPanel } from "./agent-export-panel"; +import { AgentImportPanel } from "./agent-import-panel"; +import { TeamExportPanel } from "./team-export-panel"; +import { TeamImportPanel } from "./team-import-panel"; +import { CapabilitiesExportPanel } from "./capabilities-export-panel"; +import { CapabilitiesImportPanel } from "./capabilities-import-panel"; +import { useTeams } from "@/pages/teams/hooks/use-teams"; + +export function ImportExportPage() { + const { t } = useTranslation("import-export"); + const [params, setParams] = useSearchParams(); + const { teams, loading: teamsLoading, load: loadTeams } = useTeams(); + + const scopeTab = params.get("tab") ?? "teams"; + const innerTab = params.get("inner") ?? "export"; + + const setScopeTab = (v: string) => { + const next = new URLSearchParams(params); + next.set("tab", v); + setParams(next, { replace: true }); + }; + + const setInnerTab = (v: string) => { + const next = new URLSearchParams(params); + next.set("inner", v); + setParams(next, { replace: true }); + }; + + return ( +
+
+
+

+ {t("title")} {t("beta")} +

+

{t("description")}

+
+
+ + {/* Beta warning */} +
+ + {t("betaWarning")} +
+ + {/* Scope tabs */} +
+ + + {t("tabs.teams")} + {t("tabs.agents")} + {t("tabs.skillsMcp")} + + + {/* Teams */} + + + + {t("tabs.export")} + {t("tabs.import")} + + + + + + + + + + + {/* Agents */} + + + + {t("tabs.export")} + {t("tabs.import")} + + + + + + + + + + + {/* Skills & MCP */} + + + + {t("tabs.export")} + {t("tabs.import")} + + + + + + + + + + +
+
+ ); +} diff --git a/ui/web/src/pages/import-export/section-picker.tsx b/ui/web/src/pages/import-export/section-picker.tsx new file mode 100644 index 00000000..40d301f0 --- /dev/null +++ b/ui/web/src/pages/import-export/section-picker.tsx @@ -0,0 +1,99 @@ +import { useTranslation } from "react-i18next"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; + +export interface SectionDef { + id: string; + labelKey: string; + count?: number; + countLabel?: string; + required?: boolean; + hint?: string; + comingSoon?: boolean; + children?: SectionDef[]; +} + +interface SectionPickerProps { + sections: SectionDef[]; + selected: Set; + onChange: (selected: Set) => void; + disabled?: boolean; + ns?: string; +} + +export function SectionPicker({ sections, selected, onChange, disabled, ns = "import-export" }: SectionPickerProps) { + const { t } = useTranslation(ns); + + const toggle = (id: string, checked: boolean) => { + const next = new Set(selected); + if (checked) next.add(id); + else next.delete(id); + onChange(next); + }; + + return ( +
+ {sections.map((sec) => ( +
+
+
+ + {sec.required && ( + required + )} + {sec.count != null && sec.count > 0 && ( + + {sec.countLabel ?? sec.count.toLocaleString()} + + )} + {sec.comingSoon && ( + soon + )} +
+ toggle(sec.id, v)} + disabled={disabled || sec.required || sec.comingSoon} + /> +
+ {sec.hint &&

{t(sec.hint)}

} + + {sec.children && sec.children.length > 0 && selected.has(sec.id) && ( +
+ {sec.children.map((child) => ( +
+
+ + {child.count != null && child.count > 0 && ( + {child.countLabel ?? child.count.toLocaleString()} + )} +
+ toggle(child.id, v)} + disabled={disabled} + /> +
+ ))} +
+ )} +
+ ))} +
+ ); +} + +/** Presets for agent section selection (no skills/mcp/permissions). */ +export const PRESETS: Record> = { + minimal: new Set(["config", "context_files"]), + standard: new Set(["config", "context_files", "memory", "knowledge_graph", "cron"]), + complete: new Set([ + "config", "context_files", "user_data", "user_context_files", "user_profiles", "user_overrides", + "memory", "memory_global", "memory_per_user", "knowledge_graph", "cron", "workspace", + ]), +}; diff --git a/ui/web/src/pages/import-export/team-export-panel.tsx b/ui/web/src/pages/import-export/team-export-panel.tsx new file mode 100644 index 00000000..76525462 --- /dev/null +++ b/ui/web/src/pages/import-export/team-export-panel.tsx @@ -0,0 +1,170 @@ +import { useState, useMemo, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Download, Package, Info, CheckCircle2, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Combobox } from "@/components/ui/combobox"; +import { useTeamExportPreview, useTeamExport } from "./hooks/use-team-export"; +import type { ProgressStep } from "@/components/shared/operation-progress"; +import type { TeamData } from "@/types/team"; + +/** Renders team export steps as a tree: Team node + per-agent nodes with section breakdown. */ +function ExportTree({ steps, elapsed, teamLabel }: { steps: ProgressStep[]; elapsed: number; teamLabel: string }) { + const teamStep = steps.find((s) => s.id === "team"); + const agentSteps = steps.filter((s) => s.id !== "team" && s.id !== "workspace"); + + return ( +
+ {/* Team node */} + {teamStep && ( +
+ + {teamLabel} + {teamStep.detail && ( + {teamStep.detail} + )} +
+ )} + + {/* Agent nodes */} +
+ {agentSteps.map((step) => { + const sections = step.detail?.split(" · ").filter(Boolean) ?? []; + return ( +
+
+ + {step.label} +
+ {sections.length > 0 && ( +
    + {sections.map((s, i) => ( +
  • + + {s} +
  • + ))} +
+ )} +
+ ); + })} +
+ + {/* Elapsed */} +
+ {elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed / 60)}m ${elapsed % 60}s`} +
+
+ ); +} + +function StepIcon({ status }: { status: string }) { + if (status === "done") return ; + return ; +} + +interface TeamExportPanelProps { + teams: TeamData[]; + loading: boolean; + loadTeams: () => void; +} + +export function TeamExportPanel({ teams, loading, loadTeams }: TeamExportPanelProps) { + const { t } = useTranslation("import-export"); + const [teamId, setTeamId] = useState(""); + + useEffect(() => { loadTeams(); }, [loadTeams]); + + const { data: preview, isLoading: previewLoading, error: previewError } = useTeamExportPreview(teamId || null); + const exp = useTeamExport(); + + const teamOptions = useMemo( + () => teams.map((t) => ({ value: t.id, label: t.name })), + [teams], + ); + + const team = teams.find((t) => t.id === teamId); + + if (exp.status !== "idle") { + return ( +
+

+ {exp.status === "complete" ? t("export.done") : exp.status === "error" ? t("export.errorTitle") : t("export.exporting")} +

+ + {exp.status === "error" && exp.error && ( +

{exp.error.detail}

+ )} +
+ {exp.status === "running" && } + {exp.status === "complete" && exp.downloadReady && ( + <> + + + + )} + {exp.status === "error" && } +
+
+ ); + } + + return ( +
+
+ +

{t("teamExportNote")}

+
+ +
+ + +
+ + {teamId && previewLoading && ( +

{t("teamPreview.loading")}

+ )} + + {teamId && previewError && ( +

{t("teamPreview.error")}

+ )} + + {teamId && preview && ( + <> +
+

{preview.team_name}

+
+ {t("teamPreview.members", { count: preview.members })} + {t("teamPreview.agents", { count: preview.agent_count })} + {t("teamPreview.tasks", { count: preview.tasks })} + {preview.agent_links > 0 && {t("teamPreview.links", { count: preview.agent_links })}} +
+
+ +
+ {team?.name} + +
+ + )} + + {teamId && !previewLoading && !previewError && !preview && ( +
+ +
+ )} +
+ ); +} diff --git a/ui/web/src/pages/import-export/team-import-panel.tsx b/ui/web/src/pages/import-export/team-import-panel.tsx new file mode 100644 index 00000000..ae019dfe --- /dev/null +++ b/ui/web/src/pages/import-export/team-import-panel.tsx @@ -0,0 +1,110 @@ +import { useState, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Upload, FileArchive, AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { OperationProgress } from "@/components/shared/operation-progress"; +import { useTeamImport } from "./hooks/use-team-import"; + +export function TeamImportPanel() { + const { t } = useTranslation("import-export"); + const fileRef = useRef(null); + const [file, setFile] = useState(null); + const [dragging, setDragging] = useState(false); + + const imp = useTeamImport(); + + const handleFile = useCallback((f: File) => { setFile(f); }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + const f = e.dataTransfer.files[0]; + if (f) handleFile(f); + }, [handleFile]); + + const handleSubmit = () => { + if (!file) return; + imp.startImport(file); + }; + + // Running / complete / error + if (imp.status !== "idle") { + return ( +
+

+ {imp.status === "complete" ? t("import.done") : imp.status === "error" ? t("import.errorTitle") : t("import.importing")} +

+ + {imp.status === "error" && imp.error && ( +
+

{imp.error.detail}

+
+ )} +
+ {imp.status === "running" && ( + <> +

+ + {t("teamDontClose")} +

+ + + )} + {(imp.status === "complete" || imp.status === "error") && ( + + )} +
+
+ ); + } + + // Idle + return ( +
+ {!file && ( +
{ e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + onClick={() => fileRef.current?.click()} + > + +

{t("teamDropHere")}

+

{t("teamDropFormats")}

+ { const f = e.target.files?.[0]; if (f) handleFile(f); }} + /> +
+ )} + + {file && ( + <> +
+
+ + {file.name} + {(file.size / 1024).toFixed(0)} KB +
+
+ +
+ + +
+ + )} +
+ ); +} diff --git a/ui/web/src/routes.tsx b/ui/web/src/routes.tsx index f10bb741..e11fb1b4 100644 --- a/ui/web/src/routes.tsx +++ b/ui/web/src/routes.tsx @@ -24,6 +24,9 @@ const AgentsPage = lazyWithRetry(() => const AgentCodexPoolPage = lazyWithRetry(() => import("@/pages/agents/agent-detail/agent-codex-pool-page").then((m) => ({ default: m.AgentCodexPoolPage })), ); +const ImportExportPage = lazyWithRetry(() => + import("@/pages/import-export/import-export-page").then((m) => ({ default: m.ImportExportPage })), +); const SessionsPage = lazyWithRetry(() => import("@/pages/sessions/sessions-page").then((m) => ({ default: m.SessionsPage })), ); @@ -151,6 +154,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> } />