mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-06 10:50:54 +02:00
* feat(runtimes): custom runtime names + searchable machine-grouped picker
MUL-4217. Runtime names were daemon-generated ("Claude (host)") and
uneditable, so picking one at agent-create time was painful once a
workspace had many machines.
Phase 1 — create-agent RuntimePicker: add a search box (>6 runtimes) and
group options by machine (Local/Remote/Cloud, online-first, current
machine first) reusing buildRuntimeMachines/filterRuntimeMachines. Rows
show the provider under a machine header instead of a flat repeated list.
Phase 2 — custom names: new nullable agent_runtime.custom_name column,
never written by the registration/heartbeat upsert so the daemon can't
clobber it; display is coalesce(custom_name, name) via runtimeDisplayName.
PATCH /api/runtimes/:id gains custom_name (+ apply_to_machine to name every
runtime sharing a daemon_id in one action, owner-scoped for non-admins).
Rename UI on the runtime detail page; `multica runtime rename` CLI command.
Verified: go build/vet, sqlc, handler tests (incl. new custom-name single
+ machine-fanout), 1650 views + 764 core TS tests, typecheck, locale parity.
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): address review — persist machine name on new registrations, keep custom_name in register response
Elon's review on #5070 (MUL-4217):
1. Machine name looked "lost" when a new provider registered on an
already-named machine — the new row landed with custom_name=null and
broke sharedCustomName. Now a fresh runtime inherits the machine's shared
custom name at register time (ListDaemonCustomNames + sharedDaemonCustomName),
so the machine title stays stable as providers come and go.
2. DaemonRegister rebuilt the response row by hand and dropped custom_name,
so register returned custom_name:null — inconsistent with list/get/update.
Both branches now carry CustomName.
Also: tighten the updateRuntime patch type to custom_name?: string (drop the
misleading `| null`, since the server treats null as "unchanged", not "clear").
Tests: register response preserves custom_name; new runtime inherits machine name.
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtimes): inherit machine name for failed-profile registrations too
Elon's re-review of #5070 (MUL-4217): the machine-name inheritance added
last round only covered the normal req.Runtimes path. The req.FailedProfiles
branch also upserts a daemon_id-scoped agent_runtime row (offline, profile
registration error), which shows up in the runtime list / machine grouping —
so on a named machine a failed custom-profile row landed with custom_name=NULL
and dragged the machine title back to the hostname.
Extract the inheritance into h.inheritMachineCustomName and call it from both
the normal runtime path and the failed-profile path. Add a test: named daemon
+ failed profile upsert -> the failed row's persisted custom_name is inherited.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
414 lines
12 KiB
Go
414 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/multica-ai/multica/server/internal/cli"
|
|
)
|
|
|
|
var runtimeCmd = &cobra.Command{
|
|
Use: "runtime",
|
|
Short: "Work with agent runtimes",
|
|
}
|
|
|
|
var runtimeListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List runtimes in the workspace",
|
|
RunE: runRuntimeList,
|
|
}
|
|
|
|
var runtimeUsageCmd = &cobra.Command{
|
|
Use: "usage <runtime-id>",
|
|
Short: "Get token usage for a runtime",
|
|
Args: exactArgs(1),
|
|
RunE: runRuntimeUsage,
|
|
}
|
|
|
|
var runtimeActivityCmd = &cobra.Command{
|
|
Use: "activity <runtime-id>",
|
|
Short: "Get hourly task activity for a runtime",
|
|
Args: exactArgs(1),
|
|
RunE: runRuntimeActivity,
|
|
}
|
|
|
|
var runtimeUpdateCmd = &cobra.Command{
|
|
Use: "update <runtime-id>",
|
|
Short: "Initiate a CLI update on a runtime",
|
|
Args: exactArgs(1),
|
|
RunE: runRuntimeUpdate,
|
|
}
|
|
|
|
var runtimeRenameCmd = &cobra.Command{
|
|
Use: "rename <runtime-id> <name>",
|
|
Short: "Set a custom display name for a runtime",
|
|
Long: "Set (or clear) a runtime's custom display name.\n\n" +
|
|
"Pass an empty name (\"\") to clear the custom name and fall back to the default. " +
|
|
"Use --machine to apply the name to every runtime on the same machine.",
|
|
Args: exactArgs(2),
|
|
RunE: runRuntimeRename,
|
|
}
|
|
|
|
var runtimeDeleteCmd = &cobra.Command{
|
|
Use: "delete <runtime-id>",
|
|
Short: "Delete a runtime from the workspace",
|
|
Long: "Delete a runtime registration from the workspace.\n\n" +
|
|
"By default this refuses when active agents are still bound to the runtime. " +
|
|
"Pass --cascade to archive those agents, cancel their queued/running tasks, and delete the runtime.",
|
|
Args: exactArgs(1),
|
|
RunE: runRuntimeDelete,
|
|
}
|
|
|
|
func init() {
|
|
runtimeCmd.AddCommand(runtimeListCmd)
|
|
runtimeCmd.AddCommand(runtimeUsageCmd)
|
|
runtimeCmd.AddCommand(runtimeActivityCmd)
|
|
runtimeCmd.AddCommand(runtimeUpdateCmd)
|
|
runtimeCmd.AddCommand(runtimeRenameCmd)
|
|
runtimeCmd.AddCommand(runtimeDeleteCmd)
|
|
|
|
// runtime list
|
|
runtimeListCmd.Flags().String("output", "table", "Output format: table or json")
|
|
|
|
// runtime usage
|
|
runtimeUsageCmd.Flags().String("output", "table", "Output format: table or json")
|
|
runtimeUsageCmd.Flags().Int("days", 90, "Number of days of usage data to retrieve (max 365)")
|
|
|
|
// runtime activity
|
|
runtimeActivityCmd.Flags().String("output", "table", "Output format: table or json")
|
|
|
|
// runtime update
|
|
runtimeUpdateCmd.Flags().String("target-version", "", "Target version to update to (required)")
|
|
runtimeUpdateCmd.Flags().String("output", "json", "Output format: table or json")
|
|
runtimeUpdateCmd.Flags().Bool("wait", false, "Wait for update to complete (poll until done)")
|
|
|
|
// runtime rename
|
|
runtimeRenameCmd.Flags().Bool("machine", false, "Apply the name to every runtime on the same machine")
|
|
runtimeRenameCmd.Flags().String("output", "table", "Output format: table or json")
|
|
|
|
// runtime delete
|
|
runtimeDeleteCmd.Flags().Bool("cascade", false, "Archive active agents bound to the runtime, cancel their tasks, then delete the runtime")
|
|
runtimeDeleteCmd.Flags().String("output", "table", "Output format: table or json")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Runtime commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func runRuntimeList(cmd *cobra.Command, _ []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var runtimes []map[string]any
|
|
if err := client.GetJSON(ctx, "/api/runtimes", &runtimes); err != nil {
|
|
return fmt.Errorf("list runtimes: %w", err)
|
|
}
|
|
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, runtimes)
|
|
}
|
|
|
|
headers := []string{"ID", "NAME", "MODE", "PROVIDER", "STATUS", "LAST_SEEN"}
|
|
rows := make([][]string, 0, len(runtimes))
|
|
for _, rt := range runtimes {
|
|
rows = append(rows, []string{
|
|
strVal(rt, "id"),
|
|
strVal(rt, "name"),
|
|
strVal(rt, "runtime_mode"),
|
|
strVal(rt, "provider"),
|
|
strVal(rt, "status"),
|
|
strVal(rt, "last_seen_at"),
|
|
})
|
|
}
|
|
cli.PrintTable(os.Stdout, headers, rows)
|
|
return nil
|
|
}
|
|
|
|
func runRuntimeUsage(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
days, _ := cmd.Flags().GetInt("days")
|
|
if days < 1 || days > 365 {
|
|
return fmt.Errorf("--days must be between 1 and 365")
|
|
}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var usage []map[string]any
|
|
path := fmt.Sprintf("/api/runtimes/%s/usage?days=%d", args[0], days)
|
|
if err := client.GetJSON(ctx, path, &usage); err != nil {
|
|
return fmt.Errorf("get runtime usage: %w", err)
|
|
}
|
|
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, usage)
|
|
}
|
|
|
|
headers := []string{"DATE", "PROVIDER", "MODEL", "INPUT_TOKENS", "OUTPUT_TOKENS", "CACHE_READ", "CACHE_WRITE"}
|
|
rows := make([][]string, 0, len(usage))
|
|
for _, u := range usage {
|
|
rows = append(rows, []string{
|
|
strVal(u, "date"),
|
|
strVal(u, "provider"),
|
|
strVal(u, "model"),
|
|
strVal(u, "input_tokens"),
|
|
strVal(u, "output_tokens"),
|
|
strVal(u, "cache_read_tokens"),
|
|
strVal(u, "cache_write_tokens"),
|
|
})
|
|
}
|
|
cli.PrintTable(os.Stdout, headers, rows)
|
|
return nil
|
|
}
|
|
|
|
func runRuntimeActivity(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var activity []map[string]any
|
|
if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/activity", &activity); err != nil {
|
|
return fmt.Errorf("get runtime activity: %w", err)
|
|
}
|
|
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, activity)
|
|
}
|
|
|
|
headers := []string{"HOUR", "COUNT"}
|
|
rows := make([][]string, 0, len(activity))
|
|
for _, a := range activity {
|
|
rows = append(rows, []string{
|
|
strVal(a, "hour"),
|
|
strVal(a, "count"),
|
|
})
|
|
}
|
|
cli.PrintTable(os.Stdout, headers, rows)
|
|
return nil
|
|
}
|
|
|
|
func runRuntimeDelete(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
runtimeID := args[0]
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
err = client.DeleteJSON(ctx, "/api/runtimes/"+runtimeID)
|
|
if err == nil {
|
|
return printRuntimeDeleteResult(cmd, map[string]any{
|
|
"id": runtimeID,
|
|
"deleted": true,
|
|
})
|
|
}
|
|
|
|
conflict, ok := runtimeDeleteConflict(err)
|
|
if !ok {
|
|
return fmt.Errorf("delete runtime: %w", err)
|
|
}
|
|
|
|
cascade, _ := cmd.Flags().GetBool("cascade")
|
|
if !cascade {
|
|
return fmt.Errorf(
|
|
"delete runtime: runtime has active agents bound to it (%s); archive or reassign them first, or rerun with --cascade to archive them and delete the runtime",
|
|
strings.Join(conflict.AgentDisplays(), ", "),
|
|
)
|
|
}
|
|
|
|
body := map[string]any{
|
|
"expected_active_agent_ids": conflict.AgentIDs(),
|
|
}
|
|
var result map[string]any
|
|
if err := client.PostJSON(ctx, "/api/runtimes/"+runtimeID+"/archive-agents-and-delete", body, &result); err != nil {
|
|
return fmt.Errorf("cascade delete runtime: %w", err)
|
|
}
|
|
result["id"] = runtimeID
|
|
result["deleted"] = true
|
|
return printRuntimeDeleteResult(cmd, result)
|
|
}
|
|
|
|
func runRuntimeRename(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
machine, _ := cmd.Flags().GetBool("machine")
|
|
body := map[string]any{"custom_name": args[1]}
|
|
if machine {
|
|
body["apply_to_machine"] = true
|
|
}
|
|
|
|
ctx, cancel := cli.APIContext(context.Background())
|
|
defer cancel()
|
|
|
|
var rt map[string]any
|
|
if err := client.PatchJSON(ctx, "/api/runtimes/"+args[0], body, &rt); err != nil {
|
|
return fmt.Errorf("rename runtime: %w", err)
|
|
}
|
|
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, rt)
|
|
}
|
|
|
|
if strings.TrimSpace(args[1]) == "" {
|
|
fmt.Fprintf(os.Stderr, "Custom name cleared; runtime is now %q.\n", strVal(rt, "name"))
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "Runtime renamed to %q.\n", strVal(rt, "custom_name"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runRuntimeUpdate(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
targetVersion, _ := cmd.Flags().GetString("target-version")
|
|
if targetVersion == "" {
|
|
return fmt.Errorf("--target-version is required")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), cli.AtLeastAPITimeout(150*time.Second))
|
|
defer cancel()
|
|
|
|
body := map[string]any{
|
|
"target_version": targetVersion,
|
|
}
|
|
|
|
var update map[string]any
|
|
if err := client.PostJSON(ctx, "/api/runtimes/"+args[0]+"/update", body, &update); err != nil {
|
|
return fmt.Errorf("initiate update: %w", err)
|
|
}
|
|
|
|
wait, _ := cmd.Flags().GetBool("wait")
|
|
if !wait {
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, update)
|
|
}
|
|
fmt.Printf("Update initiated: %s (status: %s)\n", strVal(update, "id"), strVal(update, "status"))
|
|
return nil
|
|
}
|
|
|
|
// Poll until completed/failed/timeout.
|
|
updateID := strVal(update, "id")
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("timed out waiting for update (last status: %s)", strVal(update, "status"))
|
|
case <-time.After(2 * time.Second):
|
|
}
|
|
|
|
if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/update/"+updateID, &update); err != nil {
|
|
return fmt.Errorf("get update status: %w", err)
|
|
}
|
|
|
|
status := strVal(update, "status")
|
|
if status == "completed" || status == "failed" || status == "timeout" {
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, update)
|
|
}
|
|
if status == "completed" {
|
|
fmt.Printf("Update completed: %s\n", strVal(update, "output"))
|
|
} else {
|
|
fmt.Printf("Update %s: %s\n", status, strVal(update, "error"))
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
type runtimeDeleteConflictPayload struct {
|
|
Code string `json:"code"`
|
|
Error string `json:"error"`
|
|
ActiveAgents []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"active_agents"`
|
|
}
|
|
|
|
func runtimeDeleteConflict(err error) (runtimeDeleteConflictPayload, bool) {
|
|
var httpErr *cli.HTTPError
|
|
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusConflict {
|
|
return runtimeDeleteConflictPayload{}, false
|
|
}
|
|
var payload runtimeDeleteConflictPayload
|
|
if json.Unmarshal([]byte(httpErr.Body), &payload) != nil {
|
|
return runtimeDeleteConflictPayload{}, false
|
|
}
|
|
if payload.Code != "runtime_has_active_agents" || len(payload.ActiveAgents) == 0 {
|
|
return runtimeDeleteConflictPayload{}, false
|
|
}
|
|
return payload, true
|
|
}
|
|
|
|
func (p runtimeDeleteConflictPayload) AgentIDs() []string {
|
|
ids := make([]string, 0, len(p.ActiveAgents))
|
|
for _, agent := range p.ActiveAgents {
|
|
if agent.ID != "" {
|
|
ids = append(ids, agent.ID)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func (p runtimeDeleteConflictPayload) AgentDisplays() []string {
|
|
displays := make([]string, 0, len(p.ActiveAgents))
|
|
for _, agent := range p.ActiveAgents {
|
|
switch {
|
|
case agent.Name != "" && agent.ID != "":
|
|
displays = append(displays, fmt.Sprintf("%s (%s)", agent.Name, agent.ID))
|
|
case agent.Name != "":
|
|
displays = append(displays, agent.Name)
|
|
case agent.ID != "":
|
|
displays = append(displays, agent.ID)
|
|
}
|
|
}
|
|
return displays
|
|
}
|
|
|
|
func printRuntimeDeleteResult(cmd *cobra.Command, result map[string]any) error {
|
|
output, _ := cmd.Flags().GetString("output")
|
|
if output == "json" {
|
|
return cli.PrintJSON(os.Stdout, result)
|
|
}
|
|
|
|
if agentsArchived, ok := result["agents_archived"]; ok {
|
|
fmt.Fprintf(os.Stderr, "Runtime %s deleted; archived %v agent(s).\n", strVal(result, "id"), agentsArchived)
|
|
return nil
|
|
}
|
|
fmt.Fprintf(os.Stderr, "Runtime %s deleted.\n", strVal(result, "id"))
|
|
return nil
|
|
}
|