mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
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.
This commit is contained in:
1 parent
7b42ab04ab
commit
db7a34e4f3
53 files changed
+8280
-9
No files matched your search
@@ -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 {
|
||||
|
||||
+32
-4
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/<rel>" → 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: <name>\ndata: <json>\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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -102,6 +102,11 @@ export class HttpClient {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** Public auth headers — for SSE streams and custom fetch calls. */
|
||||
getAuthHeaders(): Record<string, string> {
|
||||
return this.authHeaders();
|
||||
}
|
||||
|
||||
/** Auth-only headers (no Content-Type), for SSE / blob requests. */
|
||||
private authHeaders(): Record<string, string> {
|
||||
const h: Record<string, string> = {};
|
||||
|
||||
@@ -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) {
|
||||
<SidebarItem to={ROUTES.CONFIG} icon={Settings} label={t("nav.config")} collapsed={collapsed} />
|
||||
)}
|
||||
<SidebarItem to={ROUTES.APPROVALS} icon={ShieldCheck} label={t("nav.approvals")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.IMPORT_EXPORT} icon={ArrowLeftRight} label={t("nav.importExport")} collapsed={collapsed} />
|
||||
</SidebarGroup>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -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 <CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" />;
|
||||
case "error":
|
||||
return <XCircle className="h-4 w-4 shrink-0 text-destructive" />;
|
||||
case "running":
|
||||
return <Loader2 className="h-4 w-4 shrink-0 text-blue-500 animate-spin" />;
|
||||
default:
|
||||
return <Circle className="h-4 w-4 shrink-0 text-muted-foreground/40" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function OperationProgress({ steps, elapsed, className }: OperationProgressProps) {
|
||||
return (
|
||||
<div className={`rounded-lg border bg-card p-4 space-y-2.5 ${className ?? ""}`}>
|
||||
{steps.map((step) => (
|
||||
<div key={step.id}>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<StepIcon status={step.status} />
|
||||
<span className={step.status === "pending" ? "text-muted-foreground" : ""}>
|
||||
{step.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{step.detail
|
||||
? step.detail
|
||||
: step.status === "done" && step.total != null && step.total > 0
|
||||
? `${step.total} items`
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{step.status === "running" && step.total != null && step.total > 0 && (
|
||||
<div className="ml-6 mt-1.5 flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, ((step.current ?? 0) / step.total) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||
{step.current ?? 0}/{step.total}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.status === "error" && step.errorMessage && (
|
||||
<p className="ml-6 mt-1 text-xs text-destructive">{step.errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{elapsed != null && (
|
||||
<div className="text-xs text-muted-foreground pt-2 border-t">
|
||||
{elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed / 60)}m ${elapsed % 60}s`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
[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<string, string>): UseSseProgressReturn {
|
||||
const [steps, setSteps] = useState<ProgressStep[]>([]);
|
||||
const [status, setStatus] = useState<SseStatus>("idle");
|
||||
const [error, setError] = useState<SseErrorEvent | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [result, setResult] = useState<SseCompleteEvent | null>(null);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | 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 };
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
"providers": "Providers",
|
||||
"config": "Config",
|
||||
"approvals": "Approvals",
|
||||
"importExport": "Import & Export",
|
||||
"nodes": "Nodes",
|
||||
"tts": "TTS",
|
||||
"activity": "Activity",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "创建代理失败",
|
||||
|
||||
@@ -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 归档文件或点击浏览"
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"agentTeams": "Agent Team",
|
||||
"agents": "Agent",
|
||||
"approvals": "审批",
|
||||
"importExport": "Import & Export",
|
||||
"builtinTools": "内置工具",
|
||||
"channels": "Channel",
|
||||
"contacts": "联系人",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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={
|
||||
<Button onClick={() => setCreateOpen(true)} className="gap-1">
|
||||
<Plus className="h-4 w-4" /> {t("createAgent")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => navigate("/import-export?tab=agents")} className="gap-1">
|
||||
<ArrowLeftRight className="h-4 w-4" /> {t("transfer.title")}
|
||||
</Button>
|
||||
<Button onClick={() => setCreateOpen(true)} className="gap-1">
|
||||
<Plus className="h-4 w-4" /> {t("createAgent")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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<Set<string>>(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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 rounded-md border bg-muted/30 px-3 py-2">
|
||||
<Info className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">{t("export.infoNote")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5">{t("export.agent")}</Label>
|
||||
<Combobox value={agentId} onChange={setAgentId} options={agentOptions} placeholder={t("export.agentPlaceholder")} />
|
||||
</div>
|
||||
|
||||
{agentId && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t("export.presetsLabel")}:</span>
|
||||
{PRESET_KEYS.map((p) => (
|
||||
<Button
|
||||
key={p}
|
||||
variant={PRESETS[p] && setsEqual(selected, PRESETS[p]) ? "default" : "outline"}
|
||||
size="xs"
|
||||
onClick={() => handlePreset(p)}
|
||||
>
|
||||
{t(`presets.${p}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SectionPicker sections={sections} selected={selected} onChange={setSelected} />
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{agent && (agent.display_name || agent.agent_key)}
|
||||
</span>
|
||||
<Button onClick={handleExport} disabled={!agentId || selected.size === 0}>
|
||||
<Package className="mr-1.5 h-4 w-4" />
|
||||
{t("export.startExport")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Running / complete / error
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
{exp.status === "complete" ? t("export.done") : exp.status === "error" ? t("export.errorTitle") : t("export.exporting")}
|
||||
</h3>
|
||||
<OperationProgress steps={exp.steps} elapsed={exp.elapsed} />
|
||||
{exp.status === "error" && exp.error && (
|
||||
<p className="text-sm text-destructive">{exp.error.detail}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
{exp.status === "running" && <Button variant="outline" onClick={exp.cancel}>{t("common.cancel", { ns: "common" })}</Button>}
|
||||
{exp.status === "complete" && exp.downloadReady && (
|
||||
<>
|
||||
<Button variant="outline" onClick={exp.reset}>{t("export.startExport")}</Button>
|
||||
<Button onClick={exp.download}><Download className="mr-1.5 h-4 w-4" />{t("export.download")}</Button>
|
||||
</>
|
||||
)}
|
||||
{exp.status === "error" && <Button variant="outline" onClick={exp.reset}>{t("export.startExport")}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const v of a) if (!b.has(v)) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [mode, setMode] = useState<ImportMode>("new");
|
||||
const [agentKey, setAgentKey] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [keyTouched, setKeyTouched] = useState(false);
|
||||
const [mergeTarget, setMergeTarget] = useState("");
|
||||
const [mergeSelected, setMergeSelected] = useState<Set<string>>(
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
{imp.status === "complete" ? t("import.done") : imp.status === "error" ? t("import.errorTitle") : t("import.importing")}
|
||||
</h3>
|
||||
<OperationProgress steps={imp.steps} elapsed={imp.elapsed} />
|
||||
{imp.status === "error" && imp.error && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
|
||||
<p className="text-destructive font-medium">{imp.error.detail}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
{imp.status === "running" && (
|
||||
<>
|
||||
<p className="text-xs text-amber-600 mr-auto flex items-center gap-1">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{t("betaWarning")}
|
||||
</p>
|
||||
<Button variant="outline" onClick={imp.cancel}>{t("common.cancel", { ns: "common" })}</Button>
|
||||
</>
|
||||
)}
|
||||
{imp.status === "complete" && imp.result && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => { imp.clearFile(); setFile(null); }}>{t("import.startImport")}</Button>
|
||||
{imp.result.agent_id && (
|
||||
<Button onClick={() => navigate(`/agents/${imp.result!.agent_id}`)}>{t("export.done")}</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{imp.status === "error" && (
|
||||
<Button variant="outline" onClick={() => { imp.clearFile(); setFile(null); }}>{t("import.startImport")}</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Idle
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!file && (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-8 transition-colors cursor-pointer ${
|
||||
dragging ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"
|
||||
}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">{t("import.filePlaceholder")}</p>
|
||||
<p className="text-xs text-muted-foreground/60">{t("import.file")}</p>
|
||||
<input ref={fileRef} type="file" className="hidden" accept=".tar.gz,.gz,.json,.agent.json"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }} />
|
||||
</div>
|
||||
)}
|
||||
{parseError && <p className="text-sm text-destructive">{parseError}</p>}
|
||||
|
||||
{file && imp.manifest && (
|
||||
<>
|
||||
<div className="rounded-md border bg-muted/50 p-3 text-sm space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileArchive className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{(file.size / 1024).toFixed(0)} KB</span>
|
||||
</div>
|
||||
{imp.manifest.agent_key && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Agent: {imp.manifest.agent_key} · v{imp.manifest.version}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{t("import.mode")}</Label>
|
||||
<Select value={mode} onValueChange={(v) => setMode(v as ImportMode)}>
|
||||
<SelectTrigger className="mt-1 text-base md:text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">{t("import.modeNew")}</SelectItem>
|
||||
<SelectItem value="merge">{t("import.modeMerge")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{mode === "new" && (
|
||||
<>
|
||||
<div>
|
||||
<Label>{t("import.agentKey")}</Label>
|
||||
<Input value={displayName} onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder={t("import.agentKeyPlaceholder")} className="mt-1 text-base md:text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("import.agentKey")}</Label>
|
||||
<Input value={agentKey} onChange={(e) => { setAgentKey(e.target.value); setKeyTouched(true); }}
|
||||
placeholder={t("import.agentKeyPlaceholder")} className="mt-1 font-mono text-base md:text-sm" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "merge" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="mb-1.5">{t("import.targetAgent")}</Label>
|
||||
<Combobox value={mergeTarget} onChange={setMergeTarget} options={agentOptions}
|
||||
placeholder={t("import.targetAgentPlaceholder")} />
|
||||
</div>
|
||||
<SectionPicker sections={MERGE_SECTIONS} selected={mergeSelected} onChange={setMergeSelected} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => { setFile(null); imp.clearFile(); }}>{t("import.file")}</Button>
|
||||
<Button onClick={handleSubmit}
|
||||
disabled={(mode === "new" && agentKey !== "" && !isValidSlug(agentKey)) || (mode === "merge" && !mergeTarget)}>
|
||||
<Upload className="mr-1.5 h-4 w-4" />
|
||||
{t("import.startImport")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
{exp.status === "complete"
|
||||
? t("export.done")
|
||||
: exp.status === "error"
|
||||
? t("export.errorTitle")
|
||||
: t("export.exporting")}
|
||||
</h3>
|
||||
<OperationProgress steps={exp.steps} elapsed={exp.elapsed} />
|
||||
{exp.status === "error" && exp.error && (
|
||||
<p className="text-sm text-destructive">{exp.error.detail}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
{exp.status === "running" && (
|
||||
<Button variant="outline" onClick={exp.cancel}>
|
||||
{t("common.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
{exp.status === "complete" && exp.downloadReady && (
|
||||
<>
|
||||
<Button variant="outline" onClick={exp.reset}>{t("export.startExport")}</Button>
|
||||
<Button onClick={exp.download}>
|
||||
<Download className="mr-1.5 h-4 w-4" />
|
||||
{t("export.download")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{exp.status === "error" && (
|
||||
<Button variant="outline" onClick={exp.reset}>{t("export.startExport")}</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 rounded-md border bg-muted/30 px-3 py-2">
|
||||
<Info className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">{t("skillsMcp.skillsNote")}</p>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<p className="text-sm text-muted-foreground">{t("export.previewLoading")}</p>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="rounded-md border bg-muted/50 p-3 text-sm space-y-1">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{t("skillsMcp.customSkills", { count: preview.custom_skills })}</span>
|
||||
<span>{t("skillsMcp.grants", { count: preview.total_grants })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview?.custom_skills === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("skillsMcp.noSkills")}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
onClick={exp.startSkillsExport}
|
||||
disabled={isLoading || preview?.custom_skills === 0}
|
||||
>
|
||||
<Package className="mr-1.5 h-4 w-4" />
|
||||
{t("skillsMcp.exportSkills")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function McpExportTab() {
|
||||
const { t } = useTranslation("import-export");
|
||||
const { data: preview, isLoading } = useMcpExportPreview();
|
||||
const exp = useMcpExport();
|
||||
|
||||
if (exp.status !== "idle") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
{exp.status === "complete"
|
||||
? t("export.done")
|
||||
: exp.status === "error"
|
||||
? t("export.errorTitle")
|
||||
: t("export.exporting")}
|
||||
</h3>
|
||||
<OperationProgress steps={exp.steps} elapsed={exp.elapsed} />
|
||||
{exp.status === "error" && exp.error && (
|
||||
<p className="text-sm text-destructive">{exp.error.detail}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
{exp.status === "running" && (
|
||||
<Button variant="outline" onClick={exp.cancel}>
|
||||
{t("common.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
{exp.status === "complete" && exp.downloadReady && (
|
||||
<>
|
||||
<Button variant="outline" onClick={exp.reset}>{t("export.startExport")}</Button>
|
||||
<Button onClick={exp.download}>
|
||||
<Download className="mr-1.5 h-4 w-4" />
|
||||
{t("export.download")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{exp.status === "error" && (
|
||||
<Button variant="outline" onClick={exp.reset}>{t("export.startExport")}</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 rounded-md border bg-muted/30 px-3 py-2">
|
||||
<Info className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">{t("skillsMcp.mcpNote")}</p>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<p className="text-sm text-muted-foreground">{t("export.previewLoading")}</p>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="rounded-md border bg-muted/50 p-3 text-sm space-y-1">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{t("skillsMcp.servers", { count: preview.servers })}</span>
|
||||
<span>{t("skillsMcp.grants", { count: preview.agent_grants })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview?.servers === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("skillsMcp.noServers")}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
onClick={exp.startMcpExport}
|
||||
disabled={isLoading || preview?.servers === 0}
|
||||
>
|
||||
<Package className="mr-1.5 h-4 w-4" />
|
||||
{t("skillsMcp.exportMcp")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CapabilitiesExportPanel() {
|
||||
const { t } = useTranslation("import-export");
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="skills">
|
||||
<TabsList>
|
||||
<TabsTrigger value="skills">{t("skillsMcp.skillsTab")}</TabsTrigger>
|
||||
<TabsTrigger value="mcp">{t("skillsMcp.mcpTab")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="skills" className="mt-4">
|
||||
<SkillsExportTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="mcp" className="mt-4">
|
||||
<McpExportTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const [file, setFile] = useState<File | null>(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 (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
{status === "complete" ? doneLabel : status === "error" ? errorLabel : importingLabel}
|
||||
</h3>
|
||||
{status === "running" && <OperationProgress steps={steps} elapsed={elapsed} />}
|
||||
{status === "complete" && entries.length > 0 && (
|
||||
<div className="rounded-lg border bg-card divide-y text-sm">
|
||||
{entries.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-4 py-2">
|
||||
{e.type === "skipped"
|
||||
? <SkipForward className="h-4 w-4 text-muted-foreground" />
|
||||
: <CheckCircle2 className="h-4 w-4 text-green-500" />}
|
||||
<span>{e.label}</span>
|
||||
<span className="ml-auto text-xs font-medium tabular-nums">{e.value}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs text-muted-foreground px-4 py-2">
|
||||
{elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed / 60)}m ${elapsed % 60}s`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status === "complete" && entries.length === 0 && (
|
||||
<OperationProgress steps={steps} elapsed={elapsed} />
|
||||
)}
|
||||
{status === "error" && error && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
|
||||
<p className="text-destructive font-medium">{error.detail}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
{status === "running" && (
|
||||
<Button variant="outline" onClick={cancel}>
|
||||
{t("common.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
{(status === "complete" || status === "error") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setFile(null);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
{status === "complete" ? t("teamImportAnother") : t("teamTryAgain")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{infoNote && (
|
||||
<div className="flex items-start gap-2 rounded-md border bg-muted/30 px-3 py-2">
|
||||
<Info className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">{infoNote}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!file && (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-8 transition-colors cursor-pointer ${
|
||||
dragging
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-muted-foreground/25 hover:border-muted-foreground/50"
|
||||
}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">{dropLabel}</p>
|
||||
<p className="text-xs text-muted-foreground/60">{dropFormats}</p>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".tar.gz,.gz"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{file && (
|
||||
<>
|
||||
<div className="rounded-md border bg-muted/50 p-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileArchive className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{(file.size / 1024).toFixed(0)} KB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setFile(null)}>
|
||||
{t("teamChangeFile")}
|
||||
</Button>
|
||||
<Button onClick={() => onImport(file)}>
|
||||
<Upload className="mr-1.5 h-4 w-4" />
|
||||
{startLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillsImportTab() {
|
||||
const { t } = useTranslation("import-export");
|
||||
const imp = useSkillsImport();
|
||||
|
||||
return (
|
||||
<DropZoneImportTab
|
||||
dropLabel={t("skillsMcp.dropSkills")}
|
||||
dropFormats={t("teamDropFormats")}
|
||||
startLabel={t("skillsMcp.importSkills")}
|
||||
doneLabel={t("import.done")}
|
||||
errorLabel={t("import.errorTitle")}
|
||||
importingLabel={t("import.importing")}
|
||||
onImport={imp.startImport}
|
||||
status={imp.status}
|
||||
steps={imp.steps}
|
||||
elapsed={imp.elapsed}
|
||||
error={imp.error}
|
||||
result={imp.result}
|
||||
summaryEntries={(r) => [
|
||||
{ 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 (
|
||||
<DropZoneImportTab
|
||||
dropLabel={t("skillsMcp.dropMcp")}
|
||||
dropFormats={t("teamDropFormats")}
|
||||
startLabel={t("skillsMcp.importMcp")}
|
||||
doneLabel={t("import.done")}
|
||||
errorLabel={t("import.errorTitle")}
|
||||
importingLabel={t("import.importing")}
|
||||
infoNote={t("skillsMcp.mcpNote")}
|
||||
onImport={imp.startImport}
|
||||
status={imp.status}
|
||||
steps={imp.steps}
|
||||
elapsed={imp.elapsed}
|
||||
error={imp.error}
|
||||
result={imp.result}
|
||||
summaryEntries={(r) => [
|
||||
{ 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 (
|
||||
<Tabs defaultValue="skills">
|
||||
<TabsList>
|
||||
<TabsTrigger value="skills">{t("skillsMcp.skillsTab")}</TabsTrigger>
|
||||
<TabsTrigger value="mcp">{t("skillsMcp.mcpTab")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="skills" className="mt-4">
|
||||
<SkillsImportTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="mcp" className="mt-4">
|
||||
<McpImportTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -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<ExportPreview>(`/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<string | null>(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 };
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export function useImport(): UseSseProgressReturn & {
|
||||
manifest: ImportManifest | null;
|
||||
parseFile: (file: File) => Promise<ImportManifest | null>;
|
||||
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<ImportManifest | null>(null);
|
||||
|
||||
const authHeaders = useCallback(() => http.getAuthHeaders(), [http]);
|
||||
const sse = useSseProgress(authHeaders);
|
||||
|
||||
const parseFile = useCallback(async (file: File): Promise<ImportManifest | null> => {
|
||||
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 };
|
||||
}
|
||||
@@ -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<SkillsExportPreview>("/v1/skills/export/preview"),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMcpExportPreview() {
|
||||
const http = useHttp();
|
||||
return useQuery({
|
||||
queryKey: ["mcp-export-preview"],
|
||||
queryFn: () => http.get<McpExportPreview>("/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<string | null>(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`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<TeamExportPreview>(`/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<string | null>(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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="p-4 sm:p-6 pb-10 space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t("title")} <Badge variant="warning" className="ml-1 align-middle text-xs">{t("beta")}</Badge>
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Beta warning */}
|
||||
<div className="mx-auto max-w-3xl flex items-start gap-2.5 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{t("betaWarning")}</span>
|
||||
</div>
|
||||
|
||||
{/* Scope tabs */}
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Tabs value={scopeTab} onValueChange={setScopeTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="teams">{t("tabs.teams")}</TabsTrigger>
|
||||
<TabsTrigger value="agents">{t("tabs.agents")}</TabsTrigger>
|
||||
<TabsTrigger value="skills-mcp">{t("tabs.skillsMcp")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Teams */}
|
||||
<TabsContent value="teams" className="mt-4">
|
||||
<Tabs value={innerTab} onValueChange={setInnerTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="export">{t("tabs.export")}</TabsTrigger>
|
||||
<TabsTrigger value="import">{t("tabs.import")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="export" className="mt-4">
|
||||
<TeamExportPanel teams={teams} loading={teamsLoading} loadTeams={loadTeams} />
|
||||
</TabsContent>
|
||||
<TabsContent value="import" className="mt-4">
|
||||
<TeamImportPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
|
||||
{/* Agents */}
|
||||
<TabsContent value="agents" className="mt-4">
|
||||
<Tabs value={innerTab} onValueChange={setInnerTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="export">{t("tabs.export")}</TabsTrigger>
|
||||
<TabsTrigger value="import">{t("tabs.import")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="export" className="mt-4">
|
||||
<AgentExportPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="import" className="mt-4">
|
||||
<AgentImportPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
|
||||
{/* Skills & MCP */}
|
||||
<TabsContent value="skills-mcp" className="mt-4">
|
||||
<Tabs value={innerTab} onValueChange={setInnerTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="export">{t("tabs.export")}</TabsTrigger>
|
||||
<TabsTrigger value="import">{t("tabs.import")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="export" className="mt-4">
|
||||
<CapabilitiesExportPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="import" className="mt-4">
|
||||
<CapabilitiesImportPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string>;
|
||||
onChange: (selected: Set<string>) => 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 (
|
||||
<div className="rounded-lg border divide-y">
|
||||
{sections.map((sec) => (
|
||||
<div key={sec.id} className="px-3 py-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Label htmlFor={`sec-${sec.id}`} className={`cursor-pointer text-sm ${sec.required ? "font-medium" : ""}`}>
|
||||
{t(sec.labelKey)}
|
||||
</Label>
|
||||
{sec.required && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0">required</Badge>
|
||||
)}
|
||||
{sec.count != null && sec.count > 0 && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
|
||||
{sec.countLabel ?? sec.count.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{sec.comingSoon && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 shrink-0">soon</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
id={`sec-${sec.id}`}
|
||||
checked={!sec.comingSoon && (sec.required || selected.has(sec.id))}
|
||||
onCheckedChange={(v) => toggle(sec.id, v)}
|
||||
disabled={disabled || sec.required || sec.comingSoon}
|
||||
/>
|
||||
</div>
|
||||
{sec.hint && <p className="text-xs text-muted-foreground mt-0.5">{t(sec.hint)}</p>}
|
||||
|
||||
{sec.children && sec.children.length > 0 && selected.has(sec.id) && (
|
||||
<div className="mt-2 ml-4 space-y-2">
|
||||
{sec.children.map((child) => (
|
||||
<div key={child.id} className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Label htmlFor={`sec-${child.id}`} className="cursor-pointer text-xs">{t(child.labelKey)}</Label>
|
||||
{child.count != null && child.count > 0 && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{child.countLabel ?? child.count.toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
id={`sec-${child.id}`}
|
||||
checked={selected.has(child.id)}
|
||||
onCheckedChange={(v) => toggle(child.id, v)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Presets for agent section selection (no skills/mcp/permissions). */
|
||||
export const PRESETS: Record<string, ReadonlySet<string>> = {
|
||||
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",
|
||||
]),
|
||||
};
|
||||
@@ -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 (
|
||||
<div className="rounded-lg border bg-card text-sm">
|
||||
{/* Team node */}
|
||||
{teamStep && (
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
||||
<StepIcon status={teamStep.status} />
|
||||
<span className="font-medium">{teamLabel}</span>
|
||||
{teamStep.detail && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">{teamStep.detail}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent nodes */}
|
||||
<div className="divide-y">
|
||||
{agentSteps.map((step) => {
|
||||
const sections = step.detail?.split(" · ").filter(Boolean) ?? [];
|
||||
return (
|
||||
<div key={step.id} className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<StepIcon status={step.status} />
|
||||
<span className="font-medium">{step.label}</span>
|
||||
</div>
|
||||
{sections.length > 0 && (
|
||||
<ul className="mt-1.5 ml-6 space-y-0.5">
|
||||
{sections.map((s, i) => (
|
||||
<li key={i} className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground/30 shrink-0" />
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Elapsed */}
|
||||
<div className="text-xs text-muted-foreground px-4 py-2 border-t">
|
||||
{elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed / 60)}m ${elapsed % 60}s`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepIcon({ status }: { status: string }) {
|
||||
if (status === "done") return <CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" />;
|
||||
return <Loader2 className="h-4 w-4 shrink-0 text-blue-500 animate-spin" />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
{exp.status === "complete" ? t("export.done") : exp.status === "error" ? t("export.errorTitle") : t("export.exporting")}
|
||||
</h3>
|
||||
<ExportTree steps={exp.steps} elapsed={exp.elapsed} teamLabel={t("tabs.teams")} />
|
||||
{exp.status === "error" && exp.error && (
|
||||
<p className="text-sm text-destructive">{exp.error.detail}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
{exp.status === "running" && <Button variant="outline" onClick={exp.cancel}>{t("common.cancel", { ns: "common" })}</Button>}
|
||||
{exp.status === "complete" && exp.downloadReady && (
|
||||
<>
|
||||
<Button variant="outline" onClick={exp.reset}>{t("export.startExport")}</Button>
|
||||
<Button onClick={exp.download}><Download className="mr-1.5 h-4 w-4" />{t("export.download")}</Button>
|
||||
</>
|
||||
)}
|
||||
{exp.status === "error" && <Button variant="outline" onClick={exp.reset}>{t("export.startExport")}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 rounded-md border bg-muted/30 px-3 py-2">
|
||||
<Info className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">{t("teamExportNote")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5">{t("tabs.teams")}</Label>
|
||||
<Combobox
|
||||
value={teamId}
|
||||
onChange={setTeamId}
|
||||
options={teamOptions}
|
||||
placeholder={loading ? t("export.previewLoading") : t("teamSelectPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{teamId && previewLoading && (
|
||||
<p className="text-sm text-muted-foreground">{t("teamPreview.loading")}</p>
|
||||
)}
|
||||
|
||||
{teamId && previewError && (
|
||||
<p className="text-sm text-destructive">{t("teamPreview.error")}</p>
|
||||
)}
|
||||
|
||||
{teamId && preview && (
|
||||
<>
|
||||
<div className="rounded-md border bg-muted/50 p-3 text-sm space-y-1">
|
||||
<p className="font-medium">{preview.team_name}</p>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{t("teamPreview.members", { count: preview.members })}</span>
|
||||
<span>{t("teamPreview.agents", { count: preview.agent_count })}</span>
|
||||
<span>{t("teamPreview.tasks", { count: preview.tasks })}</span>
|
||||
{preview.agent_links > 0 && <span>{t("teamPreview.links", { count: preview.agent_links })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-sm text-muted-foreground">{team?.name}</span>
|
||||
<Button onClick={() => exp.startExport(teamId)} disabled={!teamId}>
|
||||
<Package className="mr-1.5 h-4 w-4" />
|
||||
{t("export.startExport")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{teamId && !previewLoading && !previewError && !preview && (
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Button onClick={() => exp.startExport(teamId)} disabled={!teamId}>
|
||||
<Package className="mr-1.5 h-4 w-4" />
|
||||
{t("export.startExport")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const [file, setFile] = useState<File | null>(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 (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
{imp.status === "complete" ? t("import.done") : imp.status === "error" ? t("import.errorTitle") : t("import.importing")}
|
||||
</h3>
|
||||
<OperationProgress steps={imp.steps} elapsed={imp.elapsed} />
|
||||
{imp.status === "error" && imp.error && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
|
||||
<p className="text-destructive font-medium">{imp.error.detail}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
{imp.status === "running" && (
|
||||
<>
|
||||
<p className="text-xs text-amber-600 mr-auto flex items-center gap-1">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{t("teamDontClose")}
|
||||
</p>
|
||||
<Button variant="outline" onClick={imp.cancel}>{t("common.cancel", { ns: "common" })}</Button>
|
||||
</>
|
||||
)}
|
||||
{(imp.status === "complete" || imp.status === "error") && (
|
||||
<Button variant="outline" onClick={() => { setFile(null); imp.reset(); }}>
|
||||
{imp.status === "complete" ? t("teamImportAnother") : t("teamTryAgain")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Idle
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!file && (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-8 transition-colors cursor-pointer ${
|
||||
dragging ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"
|
||||
}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">{t("teamDropHere")}</p>
|
||||
<p className="text-xs text-muted-foreground/60">{t("teamDropFormats")}</p>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".tar.gz,.gz"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{file && (
|
||||
<>
|
||||
<div className="rounded-md border bg-muted/50 p-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileArchive className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{(file.size / 1024).toFixed(0)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setFile(null)}>{t("teamChangeFile")}</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
<Upload className="mr-1.5 h-4 w-4" />
|
||||
{t("import.startImport")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<Route path={ROUTES.OVERVIEW} element={<OverviewPage />} />
|
||||
<Route path={ROUTES.CHAT_PATTERN} element={<ChatPage />} />
|
||||
<Route path={ROUTES.AGENTS} element={<AgentsPage key="list" />} />
|
||||
<Route path={ROUTES.IMPORT_EXPORT} element={<RequireAdmin><ImportExportPage /></RequireAdmin>} />
|
||||
<Route path={ROUTES.AGENT_CODEX_POOL} element={<RequireAdmin><AgentCodexPoolPage /></RequireAdmin>} />
|
||||
<Route path={ROUTES.AGENT_DETAIL} element={<AgentsPage key="detail" />} />
|
||||
<Route path={ROUTES.TEAMS} element={<TeamsPage key="list" />} />
|
||||
|
||||
Reference in new issue
Block a user