Files
multica/server/internal/handler/runtime_profile.go
LinYushen 1f5cb51d4e MUL-3284: Web UI + CLI (custom runtime PR3) (#4177)
* MUL-3284 PR3 (CLI): multica runtime profile subcommands + local path override

- cmd_runtime_profile.go: `multica runtime profile` group — list / create /
  update / delete against /api/workspaces/{id}/runtime-profiles, plus set-path
  / unset-path for a per-machine command override. protocol-family validated
  client-side via agent.IsSupportedType / agent.SupportedTypes; visibility
  validated; update only sends changed flags (protocol_family immutable);
  delete surfaces the server 409 body when agents are still bound.
- internal/cli/config.go: ProfileCommandOverrides map[string]string on
  CLIConfig (omitempty), through the existing marshal/unmarshal so set/unset
  round-trips without dropping other fields.
- internal/daemon: Config.ProfileCommandOverrides, loaded from CLIConfig;
  appendProfileRuntimes now prefers an override path when set AND executable,
  else falls back to exec.LookPath(command_name), else skips+logs as before.
- Tests: cmd_runtime_profile_test.go (registration, create/update/delete incl.
  bad-family + missing-flag + 409 surfacing, set/unset path round-trip,
  relative-path rejection, config preservation); cli/config round-trip;
  daemon prefers-override / falls-back-when-not-executable.

Verified: go build ./..., go vet, go test ./cmd/multica/... ./internal/daemon/...
./internal/cli/... all pass.

Co-authored-by: multica-agent <github@multica.ai>

* MUL-3284 PR3 (Web): custom runtime profiles in the Runtime page

Single-list integration — no new page, no tabs/grouping. Built-in protocol
families and custom profiles render mixed in one catalog, each row badged
built-in vs custom (progressive disclosure).

- packages/core: RUNTIME_PROFILE_PROTOCOL_FAMILIES (single-source 13-family
  whitelist, matches server agent.SupportedTypes + migration 120 CHECK) and
  RuntimeProtocolFamily / RuntimeProfile types; api client
  list/get/create/update/deleteRuntimeProfile against
  /api/workspaces/{id}/runtime-profiles; runtimes/profiles.ts query +
  mutation hooks and a 409 "agents still bound" conflict parser.
- packages/views/runtimes: runtime-profile-catalog (mixed built-in+custom
  rows), runtime-profiles-dialog (header "+ Add runtime" → step 1 pick
  protocol family → step 2 display_name/command_name/description; edit form
  for custom; admin-gated), delete-runtime-profile-dialog (confirm + graceful
  409), runtimes-page / runtime-list integration.
- i18n: new strings added to all four locales (en, zh-Hans, ja, ko).
- a11y: dialogs are focus-trapped, Esc-closable, labelled; full
  create/edit/delete flow is keyboard + screen-reader operable.

Iron rule honored: no generic per-agent args UI here (those stay on Agent
config). fixed_args is not surfaced as a general args field.

Verified: turbo typecheck + lint + test pass for @multica/core, @multica/views,
@multica/web; the @multica/web production build succeeds.

Co-authored-by: multica-agent <github@multica.ai>

* MUL-3284 PR3: hide fixed_args from Web + CLI (not yet wired to launch)

Review fix. fixed_args was surfaced as a working feature, but the daemon does
not splice it into the agent launch command — exposing it promised admins a
no-op. Per the call, remove it from every user-facing surface while keeping the
underlying column/struct "carried but not exposed".

- Web (runtime-profiles-dialog.tsx + runtime-profile-catalog.ts): drop the
  detail row, the create body field, the update patch field, and the form
  textarea; remove the parseFixedArgs/fixedArgsToText helpers and the
  fixedArgs form value. Left a NOTE pointing at the daemon TODO.
- i18n: removed the fixed_args strings from all four locales (en/zh-Hans/ja/ko).
- CLI (cmd_runtime_profile.go): removed the `--fixed-arg` flag from create and
  update and stopped sending `fixed_args`; updated the "no fields" message.
  Test now asserts the CLI never sends fixed_args.

Untouched (the carried-but-not-exposed layer): the runtime_profile.fixed_args
column, the server handler's accept/return, and the daemon's RuntimeProfile
field — all keep the existing TODO(MUL-3284) to wire it into the launch path
(with a test proving args reach the backend) before any UI/CLI re-exposes it.

Verified: turbo typecheck+lint+test pass for @multica/core and @multica/views;
go build/vet/test pass for ./cmd/multica/.

Co-authored-by: multica-agent <github@multica.ai>

* MUL-3284 PR3: stop exposing profile visibility=private (server forces workspace)

Double-review (Eve) caught a fixed_args-shaped hole: visibility=private was a
user-facing toggle (Web form + detail + CLI), but the three server read paths
(ListRuntimeProfiles, daemon ListEnabledRuntimeProfilesForWorkspace,
DaemonRegister) never enforce it — so a "private" profile's name/command would
leak to other members and could be registered by other machines' daemons
(lateral data leak). Same "don't paint a pie" fix as fixed_args: hide the
control everywhere and force the stored value.

- Server (runtime_profile.go): drop `visibility` from the create + update
  request structs; CreateRuntimeProfile always stores 'workspace'
  (runtimeProfileDefaultVisibility); UpdateRuntimeProfile no longer accepts it;
  removed validRuntimeProfileVisibility. The column + response field stay
  (always 'workspace') as the carried-but-not-exposed layer.
- Web (runtime-profiles-dialog.tsx): removed the visibility form fieldset,
  the VisibilityOption component, the detail row, the visibility state, and the
  create/update submit fields.
- i18n: removed the profile visibility strings from all four locales
  (profiles.detail.visibility, profiles.visibility.*, profiles.form.visibility_*).
  Top-level runtime/agent visibility strings are untouched.
- CLI (cmd_runtime_profile.go): removed `--visibility` from create/update and
  the VISIBILITY list column; removed validateVisibility; stopped sending the
  field.
- Tests: new TestCreateRuntimeProfile_ForcesWorkspaceVisibility (POST
  visibility:"private" -> response and DB row are 'workspace'); CLI create test
  now asserts visibility is never sent.

Follow-up MUL-3308 tracks implementing real creator-visibility (and wiring
fixed_args to the launch path); TODOs left in server/Web/CLI point to it.

Verified: turbo typecheck+lint+test pass (@multica/core, @multica/views);
go build/vet pass; go test ./cmd/multica/... and the full ./internal/handler/
suite pass against a migrated Postgres 17.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 11:38:17 +08:00

478 lines
17 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")
}
clean = append(clean, a)
}
return json.Marshal(clean)
}
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
}
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
}
h.publish(protocol.EventDaemonRegister, wsID, "member", uuidToString(member.UserID), map[string]any{
"runtime_profile_id": uuidToString(profile.ID),
})
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 cmd == "" {
writeError(w, http.StatusBadRequest, "command_name cannot be empty")
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
}
h.publish(protocol.EventDaemonRegister, wsID, "member", uuidToString(member.UserID), map[string]any{
"runtime_profile_id": uuidToString(profile.ID),
})
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
}
// Confirm the profile exists in this workspace before mutating anything.
if _, err := h.Queries.GetRuntimeProfileForWorkspace(r.Context(), db.GetRuntimeProfileForWorkspaceParams{
ID: profileUUID,
WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusNotFound, "runtime profile not found")
return
}
// Enumerate the runtime instance rows registered against this profile.
// 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
// archived agent still pointing at one of these rows would turn a bare
// delete into a 500. We refuse active agents (409) and clean archived
// agents / their archived squad+autopilot references before deleting.
runtimeIDs, err := h.Queries.ListAgentRuntimeIDsByProfile(r.Context(), profileUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to enumerate profile runtimes")
return
}
// Guard 1: refuse while any active (non-archived) agent is bound to one of
// the profile's runtimes. Keep this a 409 — deleting would orphan live
// agents.
agentCount, err := h.Queries.CountAgentsByProfile(r.Context(), profileUUID)
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
}
// Guard 2: refuse (before any teardown) if any runtime still has an active
// squad whose leader is already archived on it — same rule the
// runtime-delete path enforces. Checked per runtime up front so we never
// half-tear-down and then 409.
for _, rid := range runtimeIDs {
activeSquadCount, err := h.Queries.CountActiveSquadsWithArchivedLeadersByRuntime(r.Context(), rid)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to check runtime squad dependencies")
return
}
if activeSquadCount > 0 {
writeError(w, http.StatusConflict, "cannot delete runtime profile: a runtime has active squads led by archived agents. Archive those squads or assign them a new leader first.")
return
}
}
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)
// App-layer cascade, per runtime, mirroring DeleteAgentRuntime: pause
// autopilots pointing at the archived agents, drop archived squads led by
// them, then hard-delete the archived agents so the RESTRICT FK on
// agent.runtime_id no longer blocks removing the runtime row.
for _, rid := range runtimeIDs {
archivedAgentIDs, err := qtx.ListArchivedAgentIDsByRuntime(r.Context(), rid)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to enumerate archived agents")
return
}
if len(archivedAgentIDs) > 0 {
if err := qtx.PauseAutopilotsByAgentAssignees(r.Context(), archivedAgentIDs); err != nil {
writeError(w, http.StatusInternalServerError, "failed to pause autopilots")
return
}
}
if err := qtx.DeleteSquadsByArchivedAgentsOnRuntime(r.Context(), rid); err != nil {
writeError(w, http.StatusInternalServerError, "failed to clean up squads referencing archived agents")
return
}
if err := qtx.DeleteArchivedAgentsByRuntime(r.Context(), rid); err != nil {
writeError(w, http.StatusInternalServerError, "failed to clean up archived agents")
return
}
}
// Now the runtime rows have no agent references; remove them, then the
// profile itself.
if _, err := qtx.DeleteAgentRuntimesByProfile(r.Context(), profileUUID); err != nil {
slog.Error("DeleteAgentRuntimesByProfile failed", "error", err, "profile_id", uuidToString(profileUUID))
writeError(w, http.StatusInternalServerError, "failed to clean up runtime instances")
return
}
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).
h.publish(protocol.EventDaemonRegister, wsID, "member", uuidToString(member.UserID), map[string]any{
"deleted_runtime_profile_id": uuidToString(profileUUID),
})
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,
})
}