mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 17:18:35 +02:00
* feat(runtime): unbind agents on runtime delete instead of destroying them Deleting a runtime archived its agents and then hard-deleted the rows, so the agents and every conversation with them disappeared — while the confirmation dialog said "archive", which a user reasonably reads as recoverable. Retiring a laptop is an ordinary action; losing the agents configured on it is not an ordinary consequence. An agent is now a persistent business object and a runtime is replaceable execution capacity: deleting a runtime unbinds its agents. `runtime_id IS NULL` means unbound — orthogonal to archived — and the agent keeps its instructions, skills, chats, labels, channel installations, autopilots and task history. service.AgentReadiness already refused an agent with no runtime, so the scheduling safety gate needed no change. Two columns become nullable, not one. Without `agent_task_queue.runtime_id`, deleting the runtime still cascades the task history away (and task_message / task_usage / task_token with it), so the agents would survive with no record of anything they did — the same class of loss. A NOT VALID CHECK keeps NULL confined to history: an active task must always have a runtime, so claim / dispatch / delivery-CAS paths can never observe one without. It is written against completed_at rather than a status list so a future non-terminal status fails closed instead of slipping through. Two prerequisites this depends on: - 'deferred' (migration 128) was missing from CancelAgentTasksByRuntimeOrAgent. It went unnoticed because the delete used to cascade those rows away; with the new CHECK it would abort the delete and make the runtime undeletable. - The channel-installation / label / chat-pin / invocation-target / draft-restore cleanups were scoped to "archived agents on this runtime". Archived user agents now survive, so that scope is narrowed to kind='system' — otherwise the fix would produce a subtler loss: agent alive, configuration wiped. Also removes the squad guard that refused (409) when an active squad's leader was an archived agent on the runtime, plus the archived-squad delete that existed only to get past squad.leader_id's RESTRICT FK. The leader is no longer deleted, so nothing needs to be given up to retire a machine. Autopilots are no longer paused either: their assignee survives, and a rebind restores them without the owner having to remember to re-enable. Reason codes: an unbound agent reports agent_runtime_required, not runtime_offline. The copy for runtime_offline tells users to reconnect a machine; an unbound agent has no machine to reconnect, and the fix is to bind a runtime. Chat's bare 409 string gains the same code so the composer can offer that action. API: agents gain runtime_bound. runtime_id stays a string (empty when unbound) so installed clients keep parsing and no gated two-release rollout is needed. The confirmed-delete endpoint is /unbind-agents-and-delete; /archive-agents-and-delete still routes to it, and the compared expected_active_agent_ids set is unchanged — widening it would 409 every older client forever. Co-authored-by: multica-agent <github@multica.ai> * fix: make runtime unbinding recoverable Co-authored-by: multica-agent <github@multica.ai> * fix: address runtime unbind review nits Co-authored-by: multica-agent <github@multica.ai> * fix: resolve runtime unbind review blockers Co-authored-by: multica-agent <github@multica.ai> * fix(migrations): renumber runtime unbind after main merge Co-authored-by: multica-agent <github@multica.ai> * test(daemon): avoid late-request lease flake Co-authored-by: multica-agent <github@multica.ai> * test(autopilots): bind validation fixture runtime Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
529 lines
19 KiB
Go
529 lines
19 KiB
Go
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)
|
|
}
|