Files
viettranx 21b6c454ca feat: merge pipeline, per-user credentials, unified picker, group contacts
- 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
2026-03-29 22:33:17 +07:00

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
}