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:
Viet Tran authored and GitHub committed 2026-03-29 18:01:08 +07:00
1 parent 7b42ab04ab
commit db7a34e4f3
53 files changed
+8280 -9

No files matched your search

+4
View File
@@ -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
View File
@@ -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))
+756
View File
@@ -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
}
+142
View File
@@ -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)
}
+622
View File
@@ -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
}
+295
View File
@@ -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
}
+252
View File
@@ -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
}
+133
View File
@@ -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
}
+6
View File
@@ -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 {
+152
View File
@@ -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()
}
+163
View File
@@ -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
}
+6
View File
@@ -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 {
+190
View File
@@ -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()
}
+243
View File
@@ -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
}
+42
View File
@@ -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
}
+528
View File
@@ -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
}
+426
View File
@@ -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
}
+496
View File
@@ -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
}
+218
View File
@@ -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
}
+200
View File
@@ -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
}
+5
View File
@@ -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> = {};
+2
View File
@@ -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>
);
}
+220
View File
@@ -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 };
}
+7 -1
View File
@@ -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],
+68
View File
@@ -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"
}
}
+1
View File
@@ -32,6 +32,7 @@
"providers": "Providers",
"config": "Config",
"approvals": "Approvals",
"importExport": "Import & Export",
"nodes": "Nodes",
"tts": "TTS",
"activity": "Activity",
+68
View File
@@ -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"
}
}
+1
View File
@@ -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",
+68
View File
@@ -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 归档文件或点击浏览"
}
}
+1
View File
@@ -12,6 +12,7 @@
"agentTeams": "Agent Team",
"agents": "Agent",
"approvals": "审批",
"importExport": "Import & Export",
"builtinTools": "内置工具",
"channels": "Channel",
"contacts": "联系人",
+1
View File
@@ -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",
+9 -4
View File
@@ -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>
);
}
+4
View File
@@ -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" />} />