Files
multica/server/internal/handler/runtime_profile.go
T
2aa20b0353 ZIC-245: fix custom Oh-My-Pi runtime profile identity and discovery (#8576)
* fix(runtimes): preserve custom profile base runtime identity

Co-authored-by: multica-agent <[email protected]>

* fix(runtimes): pass both url params to the profile identity test

TestUpdateRuntimeProfile_RejectsIdentityChange nested two withURLParam
calls. That helper installs a fresh chi route context each time and does
not copy what is already there, so the outer call for profileId dropped
id: the handler answered 400 "workspace_id is required" and never
reached the immutability check the test exists to cover.

Use withURLParams, which carries existing params forward and is what the
rest of this file already uses.

Co-authored-by: multica-agent <[email protected]>

* fix(runtimes): name a runtime target the same way on every surface

The base-runtime picker spelled Oh-My-Pi with an inline ternary, so it
was the only place that used the product name. The catalog row, both
detail headers, the base-runtime detail row and the locked chip in the
form kept rendering the stored id under `capitalize` — a user picked
"Oh-My-Pi" and the next screen, and the saved profile, said "Omp".

Move the name into runtimeTypeLabel next to the target whitelist and
read it from every surface. profileRuntimeType replaces the repeated
runtime_type ?? protocol_family fallback.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
2026-09-20 12:53:52 +08:00

677 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)
}