mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 18:16:09 +00:00
- Enable merge UI for linking channel contacts to tenant_users - Contact → tenant_user resolution with cached lookup (60s TTL) - MCP per-user credentials via user-keyed connection pool - Secure CLI per-user credentials with AES-256-GCM encryption - Unified UserPickerCombobox searching contacts + tenant_users - Group contact collection with chat title in all channels - Group permission inheritance via wildcard user_id="*" - Fix heartbeat using wrong userID in group chats - Filter internal senders from contact collection - Add contact_type column (user/group) to channel_contacts - SQLite schema v2 migration for desktop edition
427 lines
13 KiB
Go
427 lines
13 KiB
Go
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/")
|
|
before, after, ok := strings.Cut(rest, "/")
|
|
if !ok {
|
|
continue
|
|
}
|
|
agentKey := before
|
|
relPath := after
|
|
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
|
|
}
|