mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
* 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>
369 lines
12 KiB
Go
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)
|
|
}
|