mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* chore: clean historical comments (MUL-8034) Co-authored-by: multica-agent <[email protected]> * docs: preserve pending Composio expiry contract Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
675 lines
25 KiB
Go
675 lines
25 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"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/internal/service"
|
|
"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. runtime_type selects a supported compatibility
|
|
// target, and its descriptor determines the underlying protocol_family.
|
|
//
|
|
// 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"`
|
|
RuntimeType string `json:"runtime_type"`
|
|
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,
|
|
RuntimeType: agent.ProfileRuntimeType(p.RuntimeType, 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"`
|
|
RuntimeType string `json:"runtime_type"`
|
|
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
|
|
}
|
|
req.RuntimeType = agent.ProfileRuntimeType(strings.TrimSpace(req.RuntimeType), req.ProtocolFamily)
|
|
family, supported := agent.RuntimeProtocolFamily(req.RuntimeType)
|
|
if !supported {
|
|
writeError(w, http.StatusBadRequest, "unsupported runtime_type: "+req.RuntimeType)
|
|
return
|
|
}
|
|
if req.ProtocolFamily != "" && req.ProtocolFamily != family {
|
|
writeError(w, http.StatusBadRequest, "protocol_family does not match runtime_type")
|
|
return
|
|
}
|
|
req.ProtocolFamily = family
|
|
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,
|
|
RuntimeType: req.RuntimeType,
|
|
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 {
|
|
RuntimeType *string `json:"runtime_type"`
|
|
ProtocolFamily *string `json:"protocol_family"`
|
|
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. runtime_type and protocol_family are 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
|
|
}
|
|
|
|
if req.RuntimeType != nil || req.ProtocolFamily != nil {
|
|
writeError(w, http.StatusBadRequest, "runtime_type and protocol_family are immutable; create a new profile")
|
|
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))
|
|
}
|
|
|
|
// maxNamedBlockingAgents caps how many agents the refusal spells out before it
|
|
// falls back to a count. Enough to recognise the machine they sit on without
|
|
// turning a CLI error into a wall of text.
|
|
const maxNamedBlockingAgents = 5
|
|
|
|
// maxReportedBlockingAgents caps both the rows read inside the delete
|
|
// transaction and the entries put on the response. A profile accumulates agents
|
|
// across every machine that registered it, and neither the sentence nor any
|
|
// client needs the whole set to do its job — the exact size travels separately
|
|
// as active_agent_count.
|
|
const maxReportedBlockingAgents = 20
|
|
|
|
// profileDeleteBlockedByAgents explains which agents are keeping this profile
|
|
// alive and, crucially, which machine each one is on.
|
|
//
|
|
// A profile is workspace-wide, so its bound agents are frequently on a
|
|
// different machine than the stale instance the user is actually trying to
|
|
// clean up. The old message said only "active agents are still bound to its
|
|
// runtimes", which left that user with no way to tell whether the blocker was
|
|
// the dead machine or the healthy one — and the natural next move, unbinding
|
|
// agents that were working fine, is exactly the damage worth preventing
|
|
// (GH #8456).
|
|
// agents is the bounded sample the query returned; its first row carries the
|
|
// full-set totals every caller here needs.
|
|
//
|
|
// The recovery paths come from those totals, never from the sample. Which rows
|
|
// survive the LIMIT is arbitrary with respect to class — twenty ordinary agents
|
|
// sorting first push the one Mika to position 21 — so advice derived from the
|
|
// visible rows would tell the user to archive blockers that cannot be archived,
|
|
// which is the defect this whole change set removes.
|
|
func profileDeleteBlockedByAgents(profileName string, agents []db.ListActiveAgentsByProfileRow) map[string]any {
|
|
if len(agents) == 0 {
|
|
// Caller only builds a refusal when there is at least one blocker.
|
|
return map[string]any{
|
|
"error": "cannot delete this custom runtime profile: active agents are still bound to its runtimes.",
|
|
"code": "runtime_profile_has_active_agents",
|
|
}
|
|
}
|
|
summary := agents[0]
|
|
total := summary.TotalCount
|
|
|
|
classes := map[blockingAgentClass]bool{
|
|
blockingAgentUser: summary.UserCount > 0,
|
|
blockingAgentMika: summary.MikaCount > 0,
|
|
blockingAgentBuilderCarrier: summary.AgentBuilderCount > 0,
|
|
blockingAgentOtherSystem: summary.OtherSystemCount > 0,
|
|
}
|
|
|
|
named := make([]string, 0, maxNamedBlockingAgents)
|
|
for _, a := range agents {
|
|
class := blockingAgentClassFromKey(a.BlockerClass)
|
|
if len(named) >= maxNamedBlockingAgents {
|
|
continue
|
|
}
|
|
runtimeName := a.RuntimeName
|
|
if a.RuntimeCustomName.Valid && strings.TrimSpace(a.RuntimeCustomName.String) != "" {
|
|
runtimeName = a.RuntimeCustomName.String
|
|
}
|
|
named = append(named, blockingAgentLabel(a.Name, runtimeName, a.RuntimeStatus, class))
|
|
}
|
|
listed := strings.Join(named, ", ")
|
|
if remaining := total - int64(len(named)); remaining > 0 {
|
|
listed = fmt.Sprintf("%s, and %d more", listed, remaining)
|
|
}
|
|
|
|
subject := "this custom runtime profile"
|
|
if strings.TrimSpace(profileName) != "" {
|
|
subject = fmt.Sprintf("the custom runtime profile %q", profileName)
|
|
}
|
|
|
|
remedies := blockingAgentRemedies(classes, blockingAgentScopeProfile)
|
|
if len(remedies) == 0 {
|
|
remedies = []string{"None of them can be released from here."}
|
|
}
|
|
|
|
resp := make([]map[string]any, len(agents))
|
|
for i, a := range agents {
|
|
resp[i] = map[string]any{
|
|
"id": uuidToString(a.ID),
|
|
"name": a.Name,
|
|
"kind": a.Kind,
|
|
"system_key": textToPtr(a.SystemKey),
|
|
"runtime_id": uuidToString(a.RuntimeID),
|
|
"runtime_name": a.RuntimeName,
|
|
// Deliberately separate from runtime_name: a client that renders
|
|
// its own copy shows custom_name ?? name, same as everywhere else.
|
|
"runtime_custom_name": textToPtr(a.RuntimeCustomName),
|
|
"runtime_status": a.RuntimeStatus,
|
|
}
|
|
}
|
|
|
|
sentences := append([]string{fmt.Sprintf(
|
|
"cannot delete %s: %d active agent(s) are still bound to its runtimes — %s.",
|
|
subject, total, listed,
|
|
)}, remedies...)
|
|
sentences = append(sentences,
|
|
"Deleting this profile removes its runtime on every machine that registered it, so agents on a machine you did not intend to touch will be affected too.")
|
|
|
|
return map[string]any{
|
|
"error": strings.Join(sentences, " "),
|
|
"code": "runtime_profile_has_active_agents",
|
|
"active_agents": resp,
|
|
// The sample above is capped; this is the real number.
|
|
"active_agent_count": total,
|
|
"active_agents_truncated": total > int64(len(resp)),
|
|
}
|
|
}
|
|
|
|
// 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 service.TeardownRuntime.
|
|
// 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.
|
|
profile, 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
|
|
}
|
|
}
|
|
|
|
// Bounded read: the guard only needs "is there at least one", and the
|
|
// refusal needs a few names plus the exact total, which rides on each row.
|
|
blockingAgents, err := qtx.ListActiveAgentsByProfile(r.Context(), db.ListActiveAgentsByProfileParams{
|
|
ProfileID: profileUUID,
|
|
WorkspaceID: wsUUID,
|
|
MaxRows: maxReportedBlockingAgents,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to check profile usage")
|
|
return
|
|
}
|
|
if len(blockingAgents) > 0 {
|
|
profileName := profile.DisplayName
|
|
if profileMissing {
|
|
profileName = ""
|
|
}
|
|
writeJSON(w, http.StatusConflict, profileDeleteBlockedByAgents(profileName, blockingAgents))
|
|
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 []service.RuntimeTeardownResult
|
|
for _, rid := range runtimeIDs {
|
|
teardown, err := service.TeardownRuntime(r.Context(), qtx, rid, service.RuntimeTeardownOptions{CancelNonTerminalTasks: true})
|
|
if err != nil {
|
|
if errors.Is(err, service.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
|
|
}
|
|
if errors.Is(err, service.ErrRuntimeWorkspaceMismatch) {
|
|
slog.Error("runtime profile delete aborted: agent workspace mismatch",
|
|
"runtime_id", uuidToString(rid), "profile_id", uuidToString(profileUUID), "error", err)
|
|
writeJSON(w, http.StatusConflict, map[string]any{
|
|
"error": "a runtime of this profile has an invalid cross-workspace agent binding.",
|
|
"code": "runtime_delete_workspace_mismatch",
|
|
})
|
|
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.
|
|
deletedRuntimes, err := qtx.DeleteAgentRuntimesByProfile(r.Context(), db.DeleteAgentRuntimesByProfileParams{
|
|
ProfileID: profileUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if 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
|
|
}
|
|
for _, runtime := range deletedRuntimes {
|
|
h.NotifyRuntimeGone(uuidToString(runtime.ID))
|
|
}
|
|
|
|
// 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)
|
|
}
|