package handler import ( "encoding/json" "errors" "log/slog" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/pkg/agent" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" ) // --------------------------------------------------------------------------- // Custom Runtime Profiles (MUL-3284) // // A runtime_profile is a workspace-level, team-shared definition of a custom // runtime — e.g. an in-house Codex wrapper. Daemons pull the enabled profiles // for their workspace, resolve command_name on PATH, and register an // agent_runtime instance carrying the profile_id. The profile only changes how // a runtime is launched/displayed; the underlying protocol_family must be a // backend Multica officially supports (validated against agent.SupportedTypes). // // Iron rule: a profile carries NO generic per-agent args. Per-agent launch args // stay on agent.custom_args. The only args field is fixed_args — args every // agent on this runtime must inherit to enter a compatible mode. // --------------------------------------------------------------------------- type RuntimeProfileResponse struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` DisplayName string `json:"display_name"` ProtocolFamily string `json:"protocol_family"` CommandName string `json:"command_name"` Description *string `json:"description"` FixedArgs []string `json:"fixed_args"` Visibility string `json:"visibility"` CreatedBy *string `json:"created_by"` Enabled bool `json:"enabled"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } func runtimeProfileToResponse(p db.RuntimeProfile) RuntimeProfileResponse { args := []string{} if len(p.FixedArgs) > 0 { _ = json.Unmarshal(p.FixedArgs, &args) if args == nil { args = []string{} } } return RuntimeProfileResponse{ ID: uuidToString(p.ID), WorkspaceID: uuidToString(p.WorkspaceID), DisplayName: p.DisplayName, ProtocolFamily: p.ProtocolFamily, CommandName: p.CommandName, Description: textToPtr(p.Description), FixedArgs: args, Visibility: p.Visibility, CreatedBy: uuidToPtr(p.CreatedBy), Enabled: p.Enabled, CreatedAt: timestampToString(p.CreatedAt), UpdatedAt: timestampToString(p.UpdatedAt), } } // NOTE: runtime_profile.visibility is intentionally NOT user-settable in v1. // The column exists and the API still returns it, but creation always forces // 'workspace': the daemon-pull, DaemonRegister and ListRuntimeProfiles read // paths do not yet enforce 'private', so accepting 'private' from a client // would silently leak a "private" profile's name/command to other members and // let other machines' daemons register it (lateral data leak). Re-expose a // visibility control only once those read paths enforce creator visibility. // Follow-up: MUL-3308. const runtimeProfileDefaultVisibility = "workspace" // marshalFixedArgs validates and JSON-encodes the fixed_args list. Each entry // must be a non-empty string; the column defaults to an empty array. func marshalFixedArgs(args []string) ([]byte, error) { if len(args) == 0 { return []byte("[]"), nil } clean := make([]string, 0, len(args)) for _, a := range args { // fixed_args are launch flags inherited by every agent on the runtime; // blank entries are always a client mistake. if strings.TrimSpace(a) == "" { return nil, errors.New("fixed_args entries must be non-empty") } if strings.ContainsRune(a, '\x00') { return nil, errors.New("fixed_args entries cannot contain NUL bytes") } clean = append(clean, a) } return json.Marshal(clean) } func validateRuntimeProfileCommandName(commandName string) error { if commandName == "" { return errors.New("command_name is required") } if strings.ContainsAny(commandName, " \t\r\n") { return errors.New("command_name must be a single executable token; put arguments in fixed_args") } if strings.ContainsRune(commandName, '\x00') { return errors.New("command_name cannot contain NUL bytes") } return nil } type createRuntimeProfileRequest struct { DisplayName string `json:"display_name"` ProtocolFamily string `json:"protocol_family"` CommandName string `json:"command_name"` Description *string `json:"description"` FixedArgs []string `json:"fixed_args"` Enabled *bool `json:"enabled"` } // CreateRuntimeProfile creates a workspace runtime profile. Admin-gated by the // router. protocol_family is validated against the agent backend whitelist. func (h *Handler) CreateRuntimeProfile(w http.ResponseWriter, r *http.Request) { wsID := strings.TrimSpace(chi.URLParam(r, "id")) member, ok := h.requireWorkspaceMember(w, r, wsID, "workspace not found") if !ok { return } wsUUID, ok := parseUUIDOrBadRequest(w, wsID, "workspace id") if !ok { return } var req createRuntimeProfileRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } req.DisplayName = strings.TrimSpace(req.DisplayName) req.ProtocolFamily = strings.TrimSpace(req.ProtocolFamily) req.CommandName = strings.TrimSpace(req.CommandName) if req.DisplayName == "" { writeError(w, http.StatusBadRequest, "display_name is required") return } if !agent.IsSupportedType(req.ProtocolFamily) { writeError(w, http.StatusBadRequest, "unsupported protocol_family: must be one of "+strings.Join(agent.SupportedTypes, ", ")) return } if req.CommandName == "" { writeError(w, http.StatusBadRequest, "command_name is required") return } if err := validateRuntimeProfileCommandName(req.CommandName); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } fixedArgs, err := marshalFixedArgs(req.FixedArgs) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } enabled := true if req.Enabled != nil { enabled = *req.Enabled } profile, err := h.Queries.CreateRuntimeProfile(r.Context(), db.CreateRuntimeProfileParams{ WorkspaceID: wsUUID, DisplayName: req.DisplayName, ProtocolFamily: req.ProtocolFamily, CommandName: req.CommandName, Description: ptrToText(req.Description), FixedArgs: fixedArgs, Visibility: runtimeProfileDefaultVisibility, CreatedBy: member.UserID, Enabled: enabled, }) if err != nil { if isUniqueViolation(err) { writeError(w, http.StatusConflict, "a runtime profile with this display_name already exists") return } slog.Error("CreateRuntimeProfile failed", "error", err, "workspace_id", wsID) writeError(w, http.StatusInternalServerError, "failed to create runtime profile") return } profileID := uuidToString(profile.ID) h.requestDaemonRuntimeProfileRefresh(wsID, profileID) h.publish(protocol.EventDaemonRegister, wsID, "member", uuidToString(member.UserID), map[string]any{ "runtime_profile_id": profileID, }) writeJSON(w, http.StatusCreated, runtimeProfileToResponse(profile)) } // ListRuntimeProfiles returns every runtime profile in the workspace. // Member-gated by the router. func (h *Handler) ListRuntimeProfiles(w http.ResponseWriter, r *http.Request) { wsID := strings.TrimSpace(chi.URLParam(r, "id")) if _, ok := h.requireWorkspaceMember(w, r, wsID, "workspace not found"); !ok { return } wsUUID, ok := parseUUIDOrBadRequest(w, wsID, "workspace id") if !ok { return } profiles, err := h.Queries.ListRuntimeProfiles(r.Context(), wsUUID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list runtime profiles") return } resp := make([]RuntimeProfileResponse, len(profiles)) for i, p := range profiles { resp[i] = runtimeProfileToResponse(p) } writeJSON(w, http.StatusOK, map[string]any{"runtime_profiles": resp}) } // GetRuntimeProfile returns one runtime profile. Member-gated by the router. func (h *Handler) GetRuntimeProfile(w http.ResponseWriter, r *http.Request) { wsID := strings.TrimSpace(chi.URLParam(r, "id")) if _, ok := h.requireWorkspaceMember(w, r, wsID, "workspace not found"); !ok { return } wsUUID, ok := parseUUIDOrBadRequest(w, wsID, "workspace id") if !ok { return } profileUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "profileId"), "profile id") if !ok { return } profile, err := h.Queries.GetRuntimeProfileForWorkspace(r.Context(), db.GetRuntimeProfileForWorkspaceParams{ ID: profileUUID, WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusNotFound, "runtime profile not found") return } writeJSON(w, http.StatusOK, runtimeProfileToResponse(profile)) } type updateRuntimeProfileRequest struct { DisplayName *string `json:"display_name"` CommandName *string `json:"command_name"` Description *string `json:"description"` FixedArgs *[]string `json:"fixed_args"` Enabled *bool `json:"enabled"` } // UpdateRuntimeProfile applies a partial update. protocol_family is immutable // (changing it would silently repoint bound agents onto a different backend). // Admin-gated by the router. func (h *Handler) UpdateRuntimeProfile(w http.ResponseWriter, r *http.Request) { wsID := strings.TrimSpace(chi.URLParam(r, "id")) member, ok := h.requireWorkspaceMember(w, r, wsID, "workspace not found") if !ok { return } wsUUID, ok := parseUUIDOrBadRequest(w, wsID, "workspace id") if !ok { return } profileUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "profileId"), "profile id") if !ok { return } var req updateRuntimeProfileRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } params := db.UpdateRuntimeProfileParams{ID: profileUUID, WorkspaceID: wsUUID} if req.DisplayName != nil { name := strings.TrimSpace(*req.DisplayName) if name == "" { writeError(w, http.StatusBadRequest, "display_name cannot be empty") return } params.DisplayName = strToText(name) } if req.CommandName != nil { cmd := strings.TrimSpace(*req.CommandName) if err := validateRuntimeProfileCommandName(cmd); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } params.CommandName = strToText(cmd) } if req.Description != nil { params.Description = ptrToText(req.Description) } if req.FixedArgs != nil { fixedArgs, err := marshalFixedArgs(*req.FixedArgs) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } params.FixedArgs = fixedArgs } if req.Enabled != nil { params.Enabled = pgtype.Bool{Bool: *req.Enabled, Valid: true} } profile, err := h.Queries.UpdateRuntimeProfile(r.Context(), params) if err != nil { if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "runtime profile not found") return } if isUniqueViolation(err) { writeError(w, http.StatusConflict, "a runtime profile with this display_name already exists") return } slog.Error("UpdateRuntimeProfile failed", "error", err, "profile_id", uuidToString(profileUUID)) writeError(w, http.StatusInternalServerError, "failed to update runtime profile") return } profileID := uuidToString(profile.ID) h.requestDaemonRuntimeProfileRefresh(wsID, profileID) h.publish(protocol.EventDaemonRegister, wsID, "member", uuidToString(member.UserID), map[string]any{ "runtime_profile_id": profileID, }) writeJSON(w, http.StatusOK, runtimeProfileToResponse(profile)) } // DeleteRuntimeProfile removes a profile and, in the same transaction, the // agent_runtime instance rows registered against it. Migration 120 dropped the // DB ON DELETE CASCADE, so this app-layer cleanup is what prevents orphaned // runtime rows. Refuses (409) while active agents are still bound to the // profile's runtimes. Admin-gated by the router. func (h *Handler) DeleteRuntimeProfile(w http.ResponseWriter, r *http.Request) { wsID := strings.TrimSpace(chi.URLParam(r, "id")) member, ok := h.requireWorkspaceMember(w, r, wsID, "workspace not found") if !ok { return } wsUUID, ok := parseUUIDOrBadRequest(w, wsID, "workspace id") if !ok { return } profileUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "profileId"), "profile id") if !ok { return } // The profile-delete cascade must run the SAME teardown the runtime-delete // path uses for each one: agent.runtime_id is ON DELETE RESTRICT, so an // agent still pointing at one of these rows would turn a bare delete into a // 500. Active agents are refused (409); everything else is unbound rather // than destroyed, exactly as in unbindRuntimeForDelete. // Guard: refuse while any active (non-archived) agent is bound to one of // the profile's runtimes. Keep this a 409 — the profile is the thing that // defines those runtimes, so the user should retire the agents or move them // deliberately instead of having them silently unbound in bulk. tx, err := h.TxStarter.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "failed to begin transaction") return } defer tx.Rollback(r.Context()) qtx := h.Queries.WithTx(tx) // Lock the profile before planning the cascade. Daemon registration takes // a conflicting KEY SHARE lock in its own transaction, so it cannot insert // a runtime after the plan and have that row escape deletion. If the profile // row is already gone, still clean up any orphaned profile_id rows. _, profileErr := qtx.LockRuntimeProfileForDelete(r.Context(), db.LockRuntimeProfileForDeleteParams{ ID: profileUUID, WorkspaceID: wsUUID, }) profileMissing := errors.Is(profileErr, pgx.ErrNoRows) if profileErr != nil && !profileMissing { writeError(w, http.StatusInternalServerError, "failed to load runtime profile") return } // Lock runtime rows in deterministic ID order. Their agent/task foreign-key // inserts take KEY SHARE locks, preventing dependencies from appearing after // the active-agent check. runtimeIDs, err := qtx.ListAgentRuntimeIDsByProfile(r.Context(), db.ListAgentRuntimeIDsByProfileParams{ ProfileID: profileUUID, WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to enumerate profile runtimes") return } if profileMissing && len(runtimeIDs) == 0 { writeError(w, http.StatusNotFound, "runtime profile not found") return } for _, runtimeID := range runtimeIDs { if _, err := qtx.ListUserAgentsByRuntimeForUpdate(r.Context(), runtimeID); err != nil { writeError(w, http.StatusInternalServerError, "failed to lock profile dependencies") return } } agentCount, err := qtx.CountAgentsByProfile(r.Context(), db.CountAgentsByProfileParams{ ProfileID: profileUUID, WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to check profile usage") return } if agentCount > 0 { writeError(w, http.StatusConflict, "cannot delete runtime profile: active agents are still bound to its runtimes") return } // App-layer cascade, per runtime, mirroring DeleteAgentRuntime: unbind the // remaining (archived) agents and their task history, cancel anything still // in flight, and hard-delete only the system agents, so removing the runtime // rows below cannot destroy an agent, a conversation or a task record. var teardowns []runtimeTeardownResult for _, rid := range runtimeIDs { teardown, err := unbindRuntimeForDelete(r.Context(), qtx, rid) if err != nil { if errors.Is(err, errRuntimeNotDrained) { slog.Error("runtime profile delete aborted: tasks not drained", "runtime_id", uuidToString(rid), "profile_id", uuidToString(profileUUID), "error", err) writeJSON(w, http.StatusConflict, map[string]any{ "error": "a runtime of this profile still has tasks in flight; retry in a moment.", "code": "runtime_delete_not_drained", }) return } slog.Error("runtime profile delete teardown failed", "runtime_id", uuidToString(rid), "profile_id", uuidToString(profileUUID), "error", err) writeError(w, http.StatusInternalServerError, "failed to unbind agents") return } teardowns = append(teardowns, teardown) } // Now the runtime rows have no agent references; remove them, then the // profile itself. if _, err := qtx.DeleteAgentRuntimesByProfile(r.Context(), db.DeleteAgentRuntimesByProfileParams{ ProfileID: profileUUID, WorkspaceID: wsUUID, }); err != nil { slog.Error("DeleteAgentRuntimesByProfile failed", "error", err, "profile_id", uuidToString(profileUUID)) writeError(w, http.StatusInternalServerError, "failed to clean up runtime instances") return } if !profileMissing { if err := qtx.DeleteRuntimeProfile(r.Context(), db.DeleteRuntimeProfileParams{ ID: profileUUID, WorkspaceID: wsUUID, }); err != nil { slog.Error("DeleteRuntimeProfile failed", "error", err, "profile_id", uuidToString(profileUUID)) writeError(w, http.StatusInternalServerError, "failed to delete runtime profile") return } } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "failed to commit transaction") return } // Tell connected clients to refetch the runtime list (instances vanished), // and fan out the per-runtime teardown so unbound agents and cancelled // tasks reach subscribers the same way the runtime-delete path emits them. profileID := uuidToString(profileUUID) userID := uuidToString(member.UserID) for _, teardown := range teardowns { h.publishRuntimeTeardown(r.Context(), teardown, wsID, userID) } h.requestDaemonRuntimeProfileRefresh(wsID, profileID) h.publish(protocol.EventDaemonRegister, wsID, "member", userID, map[string]any{ "deleted_runtime_profile_id": profileID, }) w.WriteHeader(http.StatusNoContent) } // DaemonListRuntimeProfiles serves the enabled runtime profiles for a workspace // to a daemon. The daemon resolves each profile's command_name on PATH and // registers an agent_runtime instance per profile it can run. Daemon-token // gated by the router. func (h *Handler) DaemonListRuntimeProfiles(w http.ResponseWriter, r *http.Request) { workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceId")) if !h.requireDaemonWorkspaceAccess(w, r, workspaceID) { return } wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") if !ok { return } profiles, err := h.Queries.ListEnabledRuntimeProfilesForWorkspace(r.Context(), wsUUID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list runtime profiles") return } resp := make([]RuntimeProfileResponse, len(profiles)) for i, p := range profiles { resp[i] = runtimeProfileToResponse(p) } writeJSON(w, http.StatusOK, map[string]any{ "workspace_id": workspaceID, "runtime_profiles": resp, }) } func (h *Handler) requestDaemonRuntimeProfileRefresh(workspaceID, profileID string) { if h.DaemonProfileRefresh == nil { return } h.DaemonProfileRefresh.NotifyRuntimeProfilesChanged(workspaceID, profileID) }