Files
multica/server/cmd/multica/cmd_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

369 lines
12 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
"github.com/multica-ai/multica/server/pkg/agent"
)
// ---------------------------------------------------------------------------
// `multica runtime profile ...` — custom runtime profiles (MUL-3284)
//
// A runtime profile lets a workspace declare a custom agent runtime built on
// top of a supported protocol family (the routing backend) but launched via a
// site-specific command_name (e.g. a wrapper that injects credentials). The
// profile lives server-side and is workspace-scoped; the daemon resolves the
// command_name on each host's PATH at registration time.
//
// `set-path` / `unset-path` are the per-machine escape hatch: they record a
// profile_id -> absolute executable path mapping in this machine's local CLI
// config so the daemon can launch a profile whose command isn't on PATH (or
// pick a specific install among several). That mapping never leaves the
// machine — it is not sent to the server.
// ---------------------------------------------------------------------------
var runtimeProfileCmd = &cobra.Command{
Use: "profile",
Short: "Manage custom runtime profiles",
}
var runtimeProfileListCmd = &cobra.Command{
Use: "list",
Short: "List custom runtime profiles in the workspace",
RunE: runRuntimeProfileList,
}
var runtimeProfileCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a custom runtime profile",
RunE: runRuntimeProfileCreate,
}
var runtimeProfileUpdateCmd = &cobra.Command{
Use: "update <profile-id>",
Short: "Update a custom runtime profile (protocol family is immutable)",
Args: exactArgs(1),
RunE: runRuntimeProfileUpdate,
}
var runtimeProfileDeleteCmd = &cobra.Command{
Use: "delete <profile-id>",
Short: "Delete a custom runtime profile",
Args: exactArgs(1),
RunE: runRuntimeProfileDelete,
}
var runtimeProfileSetPathCmd = &cobra.Command{
Use: "set-path <profile-id>",
Short: "Pin a per-machine executable path for a runtime profile (local only)",
Args: exactArgs(1),
RunE: runRuntimeProfileSetPath,
}
var runtimeProfileUnsetPathCmd = &cobra.Command{
Use: "unset-path <profile-id>",
Short: "Remove a per-machine executable path override for a runtime profile",
Args: exactArgs(1),
RunE: runRuntimeProfileUnsetPath,
}
func init() {
runtimeCmd.AddCommand(runtimeProfileCmd)
runtimeProfileCmd.AddCommand(runtimeProfileListCmd)
runtimeProfileCmd.AddCommand(runtimeProfileCreateCmd)
runtimeProfileCmd.AddCommand(runtimeProfileUpdateCmd)
runtimeProfileCmd.AddCommand(runtimeProfileDeleteCmd)
runtimeProfileCmd.AddCommand(runtimeProfileSetPathCmd)
runtimeProfileCmd.AddCommand(runtimeProfileUnsetPathCmd)
// list
runtimeProfileListCmd.Flags().String("output", "table", "Output format: table or json")
// create
runtimeProfileCreateCmd.Flags().String("protocol-family", "", "Supported backend the profile routes to (required)")
runtimeProfileCreateCmd.Flags().String("command-name", "", "Executable the daemon resolves on PATH (required)")
runtimeProfileCreateCmd.Flags().String("display-name", "", "Human-readable profile name (required)")
runtimeProfileCreateCmd.Flags().String("description", "", "Optional description")
runtimeProfileCreateCmd.Flags().String("output", "json", "Output format: table or json")
// update
runtimeProfileUpdateCmd.Flags().String("display-name", "", "New display name")
runtimeProfileUpdateCmd.Flags().String("command-name", "", "New command name")
runtimeProfileUpdateCmd.Flags().String("description", "", "New description")
// NOTE: a --fixed-arg flag is intentionally NOT exposed in v1. The server
// carries the fixed_args column, but the daemon does not yet pass these
// args to the agent launch command, so a CLI flag would promise admins a
// no-op. Re-add once it's wired end-to-end (TODO(MUL-3284), see
// server/internal/daemon/daemon.go).
runtimeProfileUpdateCmd.Flags().Bool("enabled", true, "Enable or disable the profile")
runtimeProfileUpdateCmd.Flags().String("output", "json", "Output format: table or json")
// set-path
runtimeProfileSetPathCmd.Flags().String("path", "", "Absolute path to the executable on this machine (required)")
}
// runtimeProfilesPath builds the workspace-scoped collection path.
func runtimeProfilesPath(workspaceID string) string {
return fmt.Sprintf("/api/workspaces/%s/runtime-profiles", workspaceID)
}
// validateProtocolFamily checks a protocol family against the canonical agent
// whitelist client-side so an obvious typo fails fast with a helpful list
// instead of an opaque server 400.
func validateProtocolFamily(family string) error {
if !agent.IsSupportedType(family) {
return fmt.Errorf("invalid --protocol-family %q: must be one of %s",
family, strings.Join(agent.SupportedTypes, ", "))
}
return nil
}
// NOTE: a --visibility flag is intentionally NOT exposed in v1. The server
// forces every profile to 'workspace' because the read paths do not yet
// enforce 'private' (exposing it would leak "private" profiles). Re-add once
// creator-visibility filtering exists. Follow-up: MUL-3308.
func runRuntimeProfileList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
workspaceID, err := requireWorkspaceID(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
var resp struct {
RuntimeProfiles []map[string]any `json:"runtime_profiles"`
}
if err := client.GetJSON(ctx, runtimeProfilesPath(workspaceID), &resp); err != nil {
return fmt.Errorf("list runtime profiles: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, resp.RuntimeProfiles)
}
printRuntimeProfileTable(resp.RuntimeProfiles)
return nil
}
func runRuntimeProfileCreate(cmd *cobra.Command, _ []string) error {
family, _ := cmd.Flags().GetString("protocol-family")
commandName, _ := cmd.Flags().GetString("command-name")
displayName, _ := cmd.Flags().GetString("display-name")
description, _ := cmd.Flags().GetString("description")
if strings.TrimSpace(family) == "" {
return fmt.Errorf("--protocol-family is required")
}
if strings.TrimSpace(commandName) == "" {
return fmt.Errorf("--command-name is required")
}
if strings.TrimSpace(displayName) == "" {
return fmt.Errorf("--display-name is required")
}
if err := validateProtocolFamily(family); err != nil {
return err
}
client, err := newAPIClient(cmd)
if err != nil {
return err
}
workspaceID, err := requireWorkspaceID(cmd)
if err != nil {
return err
}
body := map[string]any{
"display_name": displayName,
"protocol_family": family,
"command_name": commandName,
}
if description != "" {
body["description"] = description
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
var profile map[string]any
if err := client.PostJSON(ctx, runtimeProfilesPath(workspaceID), body, &profile); err != nil {
return fmt.Errorf("create runtime profile: %w", err)
}
return outputRuntimeProfile(cmd, profile)
}
func runRuntimeProfileUpdate(cmd *cobra.Command, args []string) error {
profileID := args[0]
body := map[string]any{}
if cmd.Flags().Changed("display-name") {
v, _ := cmd.Flags().GetString("display-name")
body["display_name"] = v
}
if cmd.Flags().Changed("command-name") {
v, _ := cmd.Flags().GetString("command-name")
body["command_name"] = v
}
if cmd.Flags().Changed("description") {
v, _ := cmd.Flags().GetString("description")
body["description"] = v
}
if cmd.Flags().Changed("enabled") {
v, _ := cmd.Flags().GetBool("enabled")
body["enabled"] = v
}
if len(body) == 0 {
return fmt.Errorf("no fields to update: pass at least one of --display-name, --command-name, --description, --enabled")
}
client, err := newAPIClient(cmd)
if err != nil {
return err
}
workspaceID, err := requireWorkspaceID(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
path := runtimeProfilesPath(workspaceID) + "/" + profileID
var profile map[string]any
if err := client.PatchJSON(ctx, path, body, &profile); err != nil {
return fmt.Errorf("update runtime profile: %w", err)
}
return outputRuntimeProfile(cmd, profile)
}
func runRuntimeProfileDelete(cmd *cobra.Command, args []string) error {
profileID := args[0]
client, err := newAPIClient(cmd)
if err != nil {
return err
}
workspaceID, err := requireWorkspaceID(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
path := runtimeProfilesPath(workspaceID) + "/" + profileID
if err := client.DeleteJSON(ctx, path); err != nil {
// 409 means the server refused because active agents are still bound
// to this profile. Surface the server's explanation verbatim rather
// than the generic HTTP wrapper so the user sees what to unbind.
var httpErr *cli.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusConflict {
msg := strings.TrimSpace(httpErr.Body)
if msg == "" {
msg = "profile still has active agents bound to it"
}
return fmt.Errorf("cannot delete runtime profile %s: %s", profileID, msg)
}
return fmt.Errorf("delete runtime profile: %w", err)
}
fmt.Printf("Deleted runtime profile %s\n", profileID)
return nil
}
func runRuntimeProfileSetPath(cmd *cobra.Command, args []string) error {
profileID := args[0]
path, _ := cmd.Flags().GetString("path")
path = strings.TrimSpace(path)
if path == "" {
return fmt.Errorf("--path is required")
}
if !filepath.IsAbs(path) {
return fmt.Errorf("--path must be an absolute path, got %q", path)
}
profile := resolveProfile(cmd)
cfg, err := cli.LoadCLIConfigForProfile(profile)
if err != nil {
return fmt.Errorf("load CLI config: %w", err)
}
if cfg.ProfileCommandOverrides == nil {
cfg.ProfileCommandOverrides = map[string]string{}
}
cfg.ProfileCommandOverrides[profileID] = path
if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil {
return fmt.Errorf("save CLI config: %w", err)
}
fmt.Printf("Pinned runtime profile %s to %s on this machine.\n", profileID, path)
fmt.Println("Restart the daemon for the change to take effect.")
return nil
}
func runRuntimeProfileUnsetPath(cmd *cobra.Command, args []string) error {
profileID := args[0]
profile := resolveProfile(cmd)
cfg, err := cli.LoadCLIConfigForProfile(profile)
if err != nil {
return fmt.Errorf("load CLI config: %w", err)
}
if _, ok := cfg.ProfileCommandOverrides[profileID]; !ok {
fmt.Printf("No per-machine path override set for runtime profile %s.\n", profileID)
return nil
}
delete(cfg.ProfileCommandOverrides, profileID)
if len(cfg.ProfileCommandOverrides) == 0 {
// Normalize back to nil so the key drops out of the saved JSON.
cfg.ProfileCommandOverrides = nil
}
if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil {
return fmt.Errorf("save CLI config: %w", err)
}
fmt.Printf("Removed per-machine path override for runtime profile %s.\n", profileID)
fmt.Println("Restart the daemon for the change to take effect.")
return nil
}
// outputRuntimeProfile renders a single profile honoring --output.
func outputRuntimeProfile(cmd *cobra.Command, profile map[string]any) error {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, profile)
}
printRuntimeProfileTable([]map[string]any{profile})
return nil
}
// printRuntimeProfileTable renders profiles as a stable, sorted table.
func printRuntimeProfileTable(profiles []map[string]any) {
headers := []string{"ID", "DISPLAY_NAME", "PROTOCOL_FAMILY", "COMMAND_NAME", "ENABLED"}
rows := make([][]string, 0, len(profiles))
for _, p := range profiles {
rows = append(rows, []string{
strVal(p, "id"),
strVal(p, "display_name"),
strVal(p, "protocol_family"),
strVal(p, "command_name"),
strVal(p, "enabled"),
})
}
sort.Slice(rows, func(i, j int) bool { return rows[i][1] < rows[j][1] })
cli.PrintTable(os.Stdout, headers, rows)
}