Files
multica/server/internal/handler/runtime_custom_name_test.go
Bohan Jiang fd58e13bec feat(runtimes): custom runtime names + searchable machine-grouped picker (MUL-4217) (#5070)
* 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>
2026-07-08 16:00:17 +08:00

271 lines
11 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// patchRuntimeCustomName is a small helper that PATCHes /api/runtimes/:id with
// a custom_name body as the given actor and returns the recorder.
func patchRuntimeCustomName(actorID, runtimeID string, body map[string]any) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
req := newRequestAs(actorID, http.MethodPatch, "/api/runtimes/"+runtimeID, body)
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.UpdateAgentRuntime(w, req)
return w
}
// TestUpdateAgentRuntime_CustomNamePatchApplies covers the single-runtime
// rename path (MUL-4217): a PATCH carrying custom_name sets it, an empty
// string clears it back to NULL, and an over-long value is rejected with 400.
func TestUpdateAgentRuntime_CustomNamePatchApplies(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID, runtimeOwnerID, plainMemberID := runtimeVisibilityFixture(t)
// Owner sets a custom name.
w := patchRuntimeCustomName(runtimeOwnerID, runtimeID, map[string]any{"custom_name": " Prod Box "})
if w.Code != http.StatusOK {
t.Fatalf("PATCH custom_name: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp AgentRuntimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.CustomName == nil || *resp.CustomName != "Prod Box" {
t.Fatalf("custom_name: got %v, want trimmed \"Prod Box\"", resp.CustomName)
}
// The raw daemon name is preserved alongside the override.
if resp.Name != "Visibility Test Runtime" {
t.Fatalf("name should be untouched by rename: got %q", resp.Name)
}
// Empty string clears the override back to NULL.
w = patchRuntimeCustomName(runtimeOwnerID, runtimeID, map[string]any{"custom_name": " "})
if w.Code != http.StatusOK {
t.Fatalf("PATCH clear custom_name: expected 200, got %d: %s", w.Code, w.Body.String())
}
resp = AgentRuntimeResponse{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.CustomName != nil {
t.Fatalf("custom_name should be cleared to null, got %q", *resp.CustomName)
}
// Over-long name is rejected before any mutation.
w = patchRuntimeCustomName(runtimeOwnerID, runtimeID, map[string]any{"custom_name": strings.Repeat("x", maxRuntimeCustomNameLen+1)})
if w.Code != http.StatusBadRequest {
t.Fatalf("PATCH over-long custom_name: expected 400, got %d: %s", w.Code, w.Body.String())
}
// Plain member cannot rename someone else's runtime.
w = patchRuntimeCustomName(plainMemberID, runtimeID, map[string]any{"custom_name": "hijack"})
if w.Code != http.StatusForbidden {
t.Fatalf("PATCH custom_name as plain member: expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestUpdateAgentRuntime_CustomNameMachineFanout verifies that
// apply_to_machine renames every runtime sharing a daemon_id, so a machine
// hosting several provider runtimes can be labelled in one action.
func TestUpdateAgentRuntime_CustomNameMachineFanout(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
_, runtimeOwnerID, _ := runtimeVisibilityFixture(t)
ctx := context.Background()
const daemonID = "custom-name-test-daemon"
makeRuntime := func(provider string) string {
var id string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status,
device_info, metadata, owner_id, visibility, last_seen_at
)
VALUES ($1, $2, $3, 'local', $4, 'online', 'host', '{}'::jsonb, $5, 'private', now())
RETURNING id
`, testWorkspaceID, daemonID, provider+" (host)", provider, runtimeOwnerID).Scan(&id); err != nil {
t.Fatalf("create runtime %s: %v", provider, err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, id)
})
return id
}
idA := makeRuntime("ccn_a")
idB := makeRuntime("ccn_b")
w := patchRuntimeCustomName(runtimeOwnerID, idA, map[string]any{
"custom_name": "Bohan's MacBook",
"apply_to_machine": true,
})
if w.Code != http.StatusOK {
t.Fatalf("PATCH machine rename: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Both runtimes on the daemon must now carry the name.
for _, id := range []string{idA, idB} {
var name *string
if err := testPool.QueryRow(ctx, `SELECT custom_name FROM agent_runtime WHERE id = $1`, id).Scan(&name); err != nil {
t.Fatalf("read custom_name for %s: %v", id, err)
}
if name == nil || *name != "Bohan's MacBook" {
t.Fatalf("runtime %s custom_name = %v, want machine name applied", id, name)
}
}
}
// registerRuntimeOnDaemon registers a single built-in runtime for a daemon via
// the daemon-token path and returns the first runtime object from the response.
func registerRuntimeOnDaemon(t *testing.T, daemonID, provider string) map[string]any {
t.Helper()
w := httptest.NewRecorder()
req := newDaemonTokenRequest("POST", "/api/daemon/register", map[string]any{
"workspace_id": testWorkspaceID,
"daemon_id": daemonID,
"device_name": "host",
"runtimes": []map[string]any{
{"name": provider + " (host)", "type": provider, "version": "1.0.0", "status": "online"},
},
}, testWorkspaceID, daemonID)
testHandler.DaemonRegister(w, req)
if w.Code != http.StatusOK {
t.Fatalf("register %s: expected 200, got %d: %s", provider, w.Code, w.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode register response: %v", err)
}
runtimes, ok := resp["runtimes"].([]any)
if !ok || len(runtimes) == 0 {
t.Fatalf("register %s: no runtimes in response: %v", provider, resp)
}
return runtimes[0].(map[string]any)
}
// TestDaemonRegister_PreservesCustomNameInResponse guards Elon's finding that
// the register handler reconstructed the response row by hand and dropped
// custom_name, so a re-registering (heartbeat/reconnect) runtime that already
// had a custom name came back as custom_name: null — inconsistent with
// list/get/update.
func TestDaemonRegister_PreservesCustomNameInResponse(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
const daemonID = "custom-name-register-preserve"
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE daemon_id = $1`, daemonID)
})
first := registerRuntimeOnDaemon(t, daemonID, "claude")
runtimeID := first["id"].(string)
// Simulate a rename, then re-register (the upsert preserves custom_name).
if _, err := testPool.Exec(ctx, `UPDATE agent_runtime SET custom_name = 'Prod Box' WHERE id = $1`, runtimeID); err != nil {
t.Fatalf("set custom_name: %v", err)
}
again := registerRuntimeOnDaemon(t, daemonID, "claude")
if again["custom_name"] != "Prod Box" {
t.Fatalf("register response custom_name = %v, want \"Prod Box\" (must not be dropped)", again["custom_name"])
}
}
// TestDaemonRegister_NewRuntimeInheritsMachineName guards Elon's finding that a
// machine's custom name looked "lost" once a new provider registered on it:
// the new runtime landed with custom_name = null. It should instead inherit
// the machine's shared name so the machine title stays stable.
func TestDaemonRegister_NewRuntimeInheritsMachineName(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
const daemonID = "custom-name-register-inherit"
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE daemon_id = $1`, daemonID)
})
// Provider A registers, then the whole machine is named.
first := registerRuntimeOnDaemon(t, daemonID, "claude")
idA := first["id"].(string)
if _, err := testPool.Exec(ctx, `UPDATE agent_runtime SET custom_name = 'Bohan MacBook' WHERE id = $1`, idA); err != nil {
t.Fatalf("name machine: %v", err)
}
// A brand-new provider on the same machine must inherit the machine name —
// both in the response and persisted.
second := registerRuntimeOnDaemon(t, daemonID, "codex")
if second["custom_name"] != "Bohan MacBook" {
t.Fatalf("new runtime response custom_name = %v, want inherited \"Bohan MacBook\"", second["custom_name"])
}
var persisted *string
if err := testPool.QueryRow(ctx, `SELECT custom_name FROM agent_runtime WHERE id = $1`, second["id"].(string)).Scan(&persisted); err != nil {
t.Fatalf("read persisted custom_name: %v", err)
}
if persisted == nil || *persisted != "Bohan MacBook" {
t.Fatalf("persisted custom_name = %v, want inherited \"Bohan MacBook\"", persisted)
}
}
// TestDaemonRegister_FailedProfileInheritsMachineName guards the same-class
// gap Elon flagged on re-review: the failed-custom-profile branch also writes a
// daemon_id-scoped agent_runtime row, so it must inherit the machine name too —
// otherwise enabling a custom runtime that can't resolve on a named machine
// lands a custom_name=NULL row and drags the machine title back to the hostname.
func TestDaemonRegister_FailedProfileInheritsMachineName(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
const daemonID = "custom-name-register-failed-profile"
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE daemon_id = $1`, daemonID)
})
// A named machine: register a normal runtime, then name the whole machine.
first := registerRuntimeOnDaemon(t, daemonID, "claude")
if _, err := testPool.Exec(ctx, `UPDATE agent_runtime SET custom_name = 'Bohan MacBook' WHERE id = $1`, first["id"].(string)); err != nil {
t.Fatalf("name machine: %v", err)
}
// A custom runtime profile fails to resolve on this machine.
profileID := insertRuntimeProfileFixture(t, ctx, "Custom Codex", "codex", "missing-codex")
w := httptest.NewRecorder()
req := newDaemonTokenRequest("POST", "/api/daemon/register", map[string]any{
"workspace_id": testWorkspaceID,
"daemon_id": daemonID,
"device_name": "host",
"failed_profiles": []map[string]any{
{"profile_id": profileID, "command_name": "missing-codex", "reason": "command not found on PATH"},
},
}, testWorkspaceID, daemonID)
testHandler.DaemonRegister(w, req)
if w.Code != http.StatusOK {
t.Fatalf("register failed profile: expected 200, got %d: %s", w.Code, w.Body.String())
}
// The failed-profile row must have inherited the machine name.
var name *string
if err := testPool.QueryRow(ctx, `
SELECT custom_name FROM agent_runtime
WHERE workspace_id = $1 AND daemon_id = $2 AND profile_id = $3
`, testWorkspaceID, daemonID, profileID).Scan(&name); err != nil {
t.Fatalf("read failed-profile custom_name: %v", err)
}
if name == nil || *name != "Bohan MacBook" {
t.Fatalf("failed-profile custom_name = %v, want inherited \"Bohan MacBook\"", name)
}
}