mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-15 14:19:13 +02:00
* feat: support custom runtime args Co-authored-by: multica-agent <github@multica.ai> * fix: address custom runtime review nits Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
615 lines
24 KiB
Go
615 lines
24 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
)
|
|
|
|
// TestProfileSetSignature_StableUnderReorder ensures the digest only
|
|
// captures the *content* of the profile set, not the order the server
|
|
// happened to return profiles in. Without this property the
|
|
// workspaceSyncLoop would re-register on every tick whenever the server
|
|
// shuffled rows (which the API contract does not forbid).
|
|
func TestProfileSetSignature_StableUnderReorder(t *testing.T) {
|
|
a := []RuntimeProfile{
|
|
{ID: "p-a", ProtocolFamily: "codex", CommandName: "a", Enabled: true},
|
|
{ID: "p-b", ProtocolFamily: "claude", CommandName: "b", Enabled: true},
|
|
}
|
|
b := []RuntimeProfile{
|
|
{ID: "p-b", ProtocolFamily: "claude", CommandName: "b", Enabled: true},
|
|
{ID: "p-a", ProtocolFamily: "codex", CommandName: "a", Enabled: true},
|
|
}
|
|
if profileSetSignature(a) != profileSetSignature(b) {
|
|
t.Errorf("digest must be order-independent")
|
|
}
|
|
}
|
|
|
|
// TestProfileSetSignature_DetectsRegistrationAffectingChanges asserts the
|
|
// digest covers exactly the fields the daemon sends in a Register call.
|
|
// Coverage gaps here would mean a real server-side change goes undetected
|
|
// and the user has to restart the daemon — the bug MUL-3332 is about.
|
|
func TestProfileSetSignature_DetectsRegistrationAffectingChanges(t *testing.T) {
|
|
base := []RuntimeProfile{{
|
|
ID: "p1",
|
|
ProtocolFamily: "codex",
|
|
CommandName: "company-codex",
|
|
FixedArgs: []string{"--foo"},
|
|
Visibility: "workspace",
|
|
Enabled: true,
|
|
}}
|
|
baseSig := profileSetSignature(base)
|
|
|
|
// Empty list must hash differently from a one-profile list.
|
|
if profileSetSignature(nil) == baseSig {
|
|
t.Errorf("empty list must hash differently from a populated list")
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
mutate func([]RuntimeProfile) []RuntimeProfile
|
|
}{
|
|
{"add new profile", func(in []RuntimeProfile) []RuntimeProfile {
|
|
return append(in, RuntimeProfile{ID: "p2", ProtocolFamily: "claude", CommandName: "c", Enabled: true})
|
|
}},
|
|
{"flip enabled", func(in []RuntimeProfile) []RuntimeProfile {
|
|
out := append([]RuntimeProfile(nil), in...)
|
|
out[0].Enabled = !out[0].Enabled
|
|
return out
|
|
}},
|
|
{"change command_name", func(in []RuntimeProfile) []RuntimeProfile {
|
|
out := append([]RuntimeProfile(nil), in...)
|
|
out[0].CommandName = "different-bin"
|
|
return out
|
|
}},
|
|
{"change protocol_family", func(in []RuntimeProfile) []RuntimeProfile {
|
|
out := append([]RuntimeProfile(nil), in...)
|
|
out[0].ProtocolFamily = "claude"
|
|
return out
|
|
}},
|
|
{"change fixed_args", func(in []RuntimeProfile) []RuntimeProfile {
|
|
out := append([]RuntimeProfile(nil), in...)
|
|
out[0].FixedArgs = []string{"--foo", "--bar"}
|
|
return out
|
|
}},
|
|
{"change visibility", func(in []RuntimeProfile) []RuntimeProfile {
|
|
out := append([]RuntimeProfile(nil), in...)
|
|
out[0].Visibility = "private"
|
|
return out
|
|
}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := profileSetSignature(tc.mutate(base))
|
|
if got == baseSig {
|
|
t.Errorf("digest must change when %s; baseSig=%s mutatedSig=%s",
|
|
tc.name, baseSig, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// driftFixture wires a Daemon against a fake server whose runtime-profiles
|
|
// response can be swapped at runtime. It also tracks how many times the
|
|
// server saw a /api/daemon/register, /api/daemon/runtimes/:id/recover-orphans,
|
|
// and /api/daemon/deregister call so tests can assert exactly which side
|
|
// effects the drift refresh triggered (or didn't).
|
|
type driftFixture struct {
|
|
daemon *Daemon
|
|
server *httptest.Server
|
|
registerCalls atomic.Int32
|
|
recoverOrphansCalls []string // runtime IDs the server received recover-orphans for, in order
|
|
recoverOrphansMu sync.Mutex
|
|
deregisterCalls [][]string // each entry is one Deregister call's runtime_ids payload, in order
|
|
deregisterMu sync.Mutex
|
|
currentProfiles []RuntimeProfile
|
|
}
|
|
|
|
// setProfiles swaps the profile set returned by the fake server. The
|
|
// next GetRuntimeProfiles call observes the new value. Tests in this file
|
|
// drive the fixture from a single goroutine, so the field is unguarded;
|
|
// add a mutex if a future test publishes profile updates concurrently with
|
|
// daemon background work.
|
|
func (fx *driftFixture) setProfiles(profiles []RuntimeProfile) {
|
|
fx.currentProfiles = profiles
|
|
}
|
|
|
|
// recordedRecoverOrphans returns a copy of the runtime IDs the fake server
|
|
// received /recover-orphans calls for since the fixture was created.
|
|
func (fx *driftFixture) recordedRecoverOrphans() []string {
|
|
fx.recoverOrphansMu.Lock()
|
|
defer fx.recoverOrphansMu.Unlock()
|
|
out := make([]string, len(fx.recoverOrphansCalls))
|
|
copy(out, fx.recoverOrphansCalls)
|
|
return out
|
|
}
|
|
|
|
// recordedDeregisters returns a copy of every Deregister call's runtime_ids
|
|
// payload, in the order the fake server received them.
|
|
func (fx *driftFixture) recordedDeregisters() [][]string {
|
|
fx.deregisterMu.Lock()
|
|
defer fx.deregisterMu.Unlock()
|
|
out := make([][]string, len(fx.deregisterCalls))
|
|
for i, ids := range fx.deregisterCalls {
|
|
cp := make([]string, len(ids))
|
|
copy(cp, ids)
|
|
out[i] = cp
|
|
}
|
|
return out
|
|
}
|
|
|
|
func newDriftFixture(t *testing.T, initial []RuntimeProfile) *driftFixture {
|
|
t.Helper()
|
|
fx := &driftFixture{currentProfiles: initial}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.URL.Path == "/api/daemon/register":
|
|
fx.registerCalls.Add(1)
|
|
var body struct {
|
|
Runtimes []map[string]any `json:"runtimes"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
var resp RegisterResponse
|
|
for i, rt := range body.Runtimes {
|
|
id := "rt-" + strconv.Itoa(i)
|
|
profileID, _ := rt["profile_id"].(string)
|
|
typ, _ := rt["type"].(string)
|
|
resp.Runtimes = append(resp.Runtimes, Runtime{
|
|
ID: id, Name: "n", Provider: typ, Status: "online", ProfileID: profileID,
|
|
})
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
case strings.HasPrefix(r.URL.Path, "/api/daemon/runtimes/") && strings.HasSuffix(r.URL.Path, "/recover-orphans"):
|
|
// /api/daemon/runtimes/<id>/recover-orphans
|
|
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/daemon/runtimes/"), "/")
|
|
runtimeID := parts[0]
|
|
fx.recoverOrphansMu.Lock()
|
|
fx.recoverOrphansCalls = append(fx.recoverOrphansCalls, runtimeID)
|
|
fx.recoverOrphansMu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"orphaned":0,"retried":0}`))
|
|
case r.URL.Path == "/api/daemon/deregister":
|
|
var body struct {
|
|
RuntimeIDs []string `json:"runtime_ids"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
fx.deregisterMu.Lock()
|
|
ids := make([]string, len(body.RuntimeIDs))
|
|
copy(ids, body.RuntimeIDs)
|
|
fx.deregisterCalls = append(fx.deregisterCalls, ids)
|
|
fx.deregisterMu.Unlock()
|
|
w.WriteHeader(http.StatusOK)
|
|
case strings.HasSuffix(r.URL.Path, "/runtime-profiles"):
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(RuntimeProfilesResponse{
|
|
WorkspaceID: "ws-1",
|
|
RuntimeProfiles: fx.currentProfiles,
|
|
})
|
|
default:
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
d := freshDaemon(srv.URL)
|
|
d.profileLaunchSpecs = make(map[string]profileLaunchSpec)
|
|
fx.daemon = d
|
|
fx.server = srv
|
|
return fx
|
|
}
|
|
|
|
// TestRefreshWorkspaceRuntimeProfiles_NoDrift_DoesNotReregister verifies the
|
|
// hot-path: when the server's profile set has not changed since the daemon
|
|
// last registered the workspace, the sync tick must NOT fire a re-register.
|
|
// Without this guarantee every quiet sync tick would cost an extra Register
|
|
// HTTP call per workspace.
|
|
func TestRefreshWorkspaceRuntimeProfiles_NoDrift_DoesNotReregister(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{"company-codex": "/opt/bin/company-codex"})
|
|
profiles := []RuntimeProfile{{
|
|
ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true,
|
|
}}
|
|
fx := newDriftFixture(t, profiles)
|
|
d := fx.daemon
|
|
d.cfg.Agents = map[string]AgentEntry{}
|
|
|
|
// Initial register seeds workspaceState (and ws.profileSetSig).
|
|
resp, profileSig, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("initial register: %v", err)
|
|
}
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", []string{resp.Runtimes[0].ID}, "", nil, nil)
|
|
d.workspaces["ws-1"].profileSetSig = profileSig
|
|
for _, rt := range resp.Runtimes {
|
|
d.runtimeIndex[rt.ID] = rt
|
|
}
|
|
if fx.registerCalls.Load() != 1 {
|
|
t.Fatalf("setup expected 1 register call, got %d", fx.registerCalls.Load())
|
|
}
|
|
|
|
// Server returns the same profile set: refresh must not re-register.
|
|
if err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("refreshWorkspaceRuntimeProfiles: %v", err)
|
|
}
|
|
if fx.registerCalls.Load() != 1 {
|
|
t.Errorf("no-drift refresh must not re-register; got %d total register calls", fx.registerCalls.Load())
|
|
}
|
|
}
|
|
|
|
// TestRefreshWorkspaceRuntimeProfiles_NewProfileTriggersReregister verifies
|
|
// the user-visible fix for MUL-3332: a profile created via the web UI on an
|
|
// already-tracked workspace becomes a registered runtime within one
|
|
// workspaceSyncLoop tick — no daemon restart required.
|
|
func TestRefreshWorkspaceRuntimeProfiles_NewProfileTriggersReregister(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{
|
|
"company-codex": "/opt/bin/company-codex",
|
|
"team-claude": "/opt/bin/team-claude",
|
|
})
|
|
initial := []RuntimeProfile{{
|
|
ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true,
|
|
}}
|
|
fx := newDriftFixture(t, initial)
|
|
d := fx.daemon
|
|
d.cfg.Agents = map[string]AgentEntry{}
|
|
|
|
// Initial register with one profile.
|
|
resp, profileSig, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("initial register: %v", err)
|
|
}
|
|
ids := make([]string, 0, len(resp.Runtimes))
|
|
for _, rt := range resp.Runtimes {
|
|
ids = append(ids, rt.ID)
|
|
d.runtimeIndex[rt.ID] = rt
|
|
}
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", ids, "", nil, nil)
|
|
d.workspaces["ws-1"].profileSetSig = profileSig
|
|
beforeRegisterCalls := fx.registerCalls.Load()
|
|
|
|
// User adds a second profile via the web UI: server's response now
|
|
// includes prof-2.
|
|
fx.setProfiles([]RuntimeProfile{
|
|
{ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true},
|
|
{ID: "prof-2", WorkspaceID: "ws-1", DisplayName: "Team Claude",
|
|
ProtocolFamily: "claude", CommandName: "team-claude",
|
|
Visibility: "workspace", Enabled: true},
|
|
})
|
|
|
|
if err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("refreshWorkspaceRuntimeProfiles: %v", err)
|
|
}
|
|
|
|
// Drift must fire a new register call.
|
|
if got := fx.registerCalls.Load(); got != beforeRegisterCalls+1 {
|
|
t.Errorf("new profile must trigger one re-register; before=%d after=%d", beforeRegisterCalls, got)
|
|
}
|
|
|
|
// The daemon's runtimeIndex must now hold a runtime for the new profile.
|
|
d.mu.Lock()
|
|
var seenProf2 bool
|
|
for _, rt := range d.runtimeIndex {
|
|
if rt.ProfileID == "prof-2" {
|
|
seenProf2 = true
|
|
break
|
|
}
|
|
}
|
|
d.mu.Unlock()
|
|
if !seenProf2 {
|
|
t.Errorf("expected runtimeIndex to contain a runtime for prof-2 after refresh")
|
|
}
|
|
|
|
// And the cached signature must now match the new profile set, so a
|
|
// follow-up refresh with no further changes is a no-op.
|
|
stableCalls := fx.registerCalls.Load()
|
|
if err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("second refresh: %v", err)
|
|
}
|
|
if got := fx.registerCalls.Load(); got != stableCalls {
|
|
t.Errorf("steady-state refresh must not re-register; before=%d after=%d", stableCalls, got)
|
|
}
|
|
|
|
// MUL-3332 review (concern 1): the drift path MUST NOT call recover-
|
|
// orphans on any returned runtime. Recover-orphans hard-fails every
|
|
// dispatched/running task on a runtime, so calling it for a built-in
|
|
// runtime that the user had been actively running tasks on would kill
|
|
// real work just because they added an unrelated sibling profile. The
|
|
// runtime_gone recovery path keeps recover-orphans because those rows
|
|
// were truly deleted server-side; drift surfaces existing runtimes.
|
|
if got := fx.recordedRecoverOrphans(); len(got) != 0 {
|
|
t.Errorf("drift path must not trigger recover-orphans for any runtime; got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestRefreshWorkspaceRuntimeProfiles_DriftWithRunningRuntimeSkipsOrphanRecovery
|
|
// is the targeted regression for MUL-3332 review concern #1: a daemon that
|
|
// is actively executing tasks on its existing runtime (built-in or
|
|
// previously-registered profile) must NOT have those tasks killed when
|
|
// the user adds a *new* sibling profile. Adding a profile must surface as
|
|
// a re-register that includes the new profile while LEAVING the existing
|
|
// runtime IDs untouched and recover-orphans untriggered.
|
|
func TestRefreshWorkspaceRuntimeProfiles_DriftWithRunningRuntimeSkipsOrphanRecovery(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{
|
|
"company-codex": "/opt/bin/company-codex",
|
|
"team-claude": "/opt/bin/team-claude",
|
|
})
|
|
// Mixed setup: one built-in runtime (claude) plus one custom profile,
|
|
// the closest analogue of "a daemon that is running real work and the
|
|
// user just added another profile". Profile drift fires next.
|
|
initial := []RuntimeProfile{{
|
|
ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true,
|
|
}}
|
|
fx := newDriftFixture(t, initial)
|
|
d := fx.daemon
|
|
d.cfg.Agents = map[string]AgentEntry{"claude": {Path: "/usr/bin/true"}}
|
|
|
|
resp, profileSig, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("initial register: %v", err)
|
|
}
|
|
ids := make([]string, 0, len(resp.Runtimes))
|
|
for _, rt := range resp.Runtimes {
|
|
ids = append(ids, rt.ID)
|
|
d.runtimeIndex[rt.ID] = rt
|
|
}
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", ids, "", nil, nil)
|
|
d.workspaces["ws-1"].profileSetSig = profileSig
|
|
if len(ids) < 2 {
|
|
t.Fatalf("setup expected at least 2 runtimes (built-in + custom); got %d", len(ids))
|
|
}
|
|
|
|
// User adds a second profile.
|
|
fx.setProfiles([]RuntimeProfile{
|
|
{ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true},
|
|
{ID: "prof-2", WorkspaceID: "ws-1", DisplayName: "Team Claude",
|
|
ProtocolFamily: "claude", CommandName: "team-claude",
|
|
Visibility: "workspace", Enabled: true},
|
|
})
|
|
|
|
if err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("refreshWorkspaceRuntimeProfiles: %v", err)
|
|
}
|
|
|
|
// Hard regression: zero recover-orphans calls for ANY runtime ID.
|
|
// The whole point of MUL-3332 review concern #1 is that adding a
|
|
// profile must not nuke in-flight work on existing runtimes.
|
|
if got := fx.recordedRecoverOrphans(); len(got) != 0 {
|
|
t.Errorf("drift refresh leaked recover-orphans calls (would fail running tasks on existing runtimes): %v", got)
|
|
}
|
|
}
|
|
|
|
// TestRefreshWorkspaceRuntimeProfiles_DisableConvergesCustomOnlyDaemon is the
|
|
// targeted regression for MUL-3332 review concern #2: when a custom-only
|
|
// daemon (no built-in agents) has its only enabled profile disabled, the
|
|
// daemon must converge to a zero-runtime state — clear local tracking AND
|
|
// tell the server to mark the orphaned runtime row offline immediately,
|
|
// rather than leaving the daemon polling/heartbeating a runtime the user
|
|
// can no longer use.
|
|
func TestRefreshWorkspaceRuntimeProfiles_DisableConvergesCustomOnlyDaemon(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{"company-codex": "/opt/bin/company-codex"})
|
|
initial := []RuntimeProfile{{
|
|
ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true,
|
|
}}
|
|
fx := newDriftFixture(t, initial)
|
|
d := fx.daemon
|
|
// Custom-only daemon: no built-in agents at all.
|
|
d.cfg.Agents = map[string]AgentEntry{}
|
|
|
|
// Initial register: one custom runtime.
|
|
resp, profileSig, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("initial register: %v", err)
|
|
}
|
|
if len(resp.Runtimes) != 1 {
|
|
t.Fatalf("setup expected exactly one runtime; got %d", len(resp.Runtimes))
|
|
}
|
|
initialRuntimeID := resp.Runtimes[0].ID
|
|
d.runtimeIndex[initialRuntimeID] = resp.Runtimes[0]
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", []string{initialRuntimeID}, "", nil, nil)
|
|
d.workspaces["ws-1"].profileSetSig = profileSig
|
|
|
|
// User disables the only profile: server's daemon-facing list now
|
|
// returns zero enabled profiles.
|
|
fx.setProfiles(nil)
|
|
|
|
if err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("refreshWorkspaceRuntimeProfiles: %v", err)
|
|
}
|
|
|
|
// Local tracking must be empty: the daemon stops polling/heartbeating
|
|
// runtimes that no longer represent anything the user can run.
|
|
d.mu.Lock()
|
|
gotRuntimeIDs := append([]string(nil), d.workspaces["ws-1"].runtimeIDs...)
|
|
_, stillIndexed := d.runtimeIndex[initialRuntimeID]
|
|
gotSig := d.workspaces["ws-1"].profileSetSig
|
|
d.mu.Unlock()
|
|
if len(gotRuntimeIDs) != 0 {
|
|
t.Errorf("workspaceState.runtimeIDs must be empty after convergence-to-zero; got %v", gotRuntimeIDs)
|
|
}
|
|
if stillIndexed {
|
|
t.Errorf("runtimeIndex must drop the previously-tracked runtime %q after convergence-to-zero", initialRuntimeID)
|
|
}
|
|
if gotSig != profileSetSignature(nil) {
|
|
t.Errorf("converged signature must match the empty-profile-list digest so the next sync tick is a no-op; got %q want %q", gotSig, profileSetSignature(nil))
|
|
}
|
|
|
|
// Server-side cleanup: the daemon must Deregister the orphaned runtime
|
|
// ID so the runtime row goes offline immediately instead of waiting
|
|
// 150 s for the stale-heartbeat sweep.
|
|
deregs := fx.recordedDeregisters()
|
|
if len(deregs) == 0 {
|
|
t.Fatalf("expected one Deregister call for the orphaned runtime ID; got none")
|
|
}
|
|
var sawInitial bool
|
|
for _, ids := range deregs {
|
|
for _, id := range ids {
|
|
if id == initialRuntimeID {
|
|
sawInitial = true
|
|
}
|
|
}
|
|
}
|
|
if !sawInitial {
|
|
t.Errorf("Deregister payload must include the initial runtime ID %q; got %v", initialRuntimeID, deregs)
|
|
}
|
|
|
|
// Convergence-to-zero must NOT call recover-orphans either: the daemon
|
|
// has nothing actively running for the user once they disabled the
|
|
// profile, but if it had, killing those tasks for a profile DISABLE
|
|
// (vs delete) would still be wrong — the user might re-enable.
|
|
if got := fx.recordedRecoverOrphans(); len(got) != 0 {
|
|
t.Errorf("convergence-to-zero must not trigger recover-orphans; got %v", got)
|
|
}
|
|
|
|
// Steady state: a follow-up refresh with the same (empty) profile set
|
|
// is a no-op. No new register / deregister / recover-orphans calls.
|
|
stableRegisters := fx.registerCalls.Load()
|
|
stableDeregs := len(fx.recordedDeregisters())
|
|
if err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("second refresh after convergence: %v", err)
|
|
}
|
|
if got := fx.registerCalls.Load(); got != stableRegisters {
|
|
t.Errorf("converged steady state must not re-register; before=%d after=%d", stableRegisters, got)
|
|
}
|
|
if got := len(fx.recordedDeregisters()); got != stableDeregs {
|
|
t.Errorf("converged steady state must not deregister again; before=%d after=%d", stableDeregs, got)
|
|
}
|
|
}
|
|
|
|
// TestRefreshWorkspaceRuntimeProfiles_DisableOneOfManyDeregistersDroppedID
|
|
// covers the partial-drift case: a daemon hosting both a built-in runtime
|
|
// and a custom profile has its custom profile disabled. The built-in
|
|
// survives (Register call carries it forward), the daemon's runtime set
|
|
// converges to just the built-in, and the dropped custom runtime ID is
|
|
// Deregistered so the server marks it offline immediately.
|
|
func TestRefreshWorkspaceRuntimeProfiles_DisableOneOfManyDeregistersDroppedID(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{"company-codex": "/opt/bin/company-codex"})
|
|
initial := []RuntimeProfile{{
|
|
ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true,
|
|
}}
|
|
fx := newDriftFixture(t, initial)
|
|
d := fx.daemon
|
|
// Mixed: one built-in + one custom.
|
|
d.cfg.Agents = map[string]AgentEntry{"claude": {Path: "/usr/bin/true"}}
|
|
|
|
resp, profileSig, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("initial register: %v", err)
|
|
}
|
|
if len(resp.Runtimes) != 2 {
|
|
t.Fatalf("setup expected 2 runtimes; got %d (%+v)", len(resp.Runtimes), resp.Runtimes)
|
|
}
|
|
var customID string
|
|
for _, rt := range resp.Runtimes {
|
|
d.runtimeIndex[rt.ID] = rt
|
|
if rt.ProfileID == "prof-1" {
|
|
customID = rt.ID
|
|
}
|
|
}
|
|
if customID == "" {
|
|
t.Fatalf("setup expected a runtime for prof-1; got %+v", resp.Runtimes)
|
|
}
|
|
initialIDs := []string{resp.Runtimes[0].ID, resp.Runtimes[1].ID}
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", initialIDs, "", nil, nil)
|
|
d.workspaces["ws-1"].profileSetSig = profileSig
|
|
|
|
// User disables the custom profile.
|
|
fx.setProfiles(nil)
|
|
|
|
if err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("refreshWorkspaceRuntimeProfiles: %v", err)
|
|
}
|
|
|
|
// The custom runtime ID must be Deregistered.
|
|
deregs := fx.recordedDeregisters()
|
|
var sawCustom bool
|
|
for _, ids := range deregs {
|
|
for _, id := range ids {
|
|
if id == customID {
|
|
sawCustom = true
|
|
}
|
|
}
|
|
}
|
|
if !sawCustom {
|
|
t.Errorf("Deregister payload must include the dropped custom runtime ID %q; got %v", customID, deregs)
|
|
}
|
|
|
|
// Surviving built-in must NOT be in any deregister payload.
|
|
for _, ids := range deregs {
|
|
for _, id := range ids {
|
|
if id != customID {
|
|
t.Errorf("Deregister leaked surviving runtime ID %q (only %q should be deregistered)", id, customID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// And the still-surviving built-in must NOT have recover-orphans called
|
|
// against it (concern #1 again, partial-drift flavour).
|
|
if got := fx.recordedRecoverOrphans(); len(got) != 0 {
|
|
t.Errorf("partial-drift refresh leaked recover-orphans calls: %v", got)
|
|
}
|
|
}
|
|
|
|
// TestRefreshWorkspaceRuntimeProfiles_FetchErrorIsBestEffort verifies that
|
|
// a network blip or older server (404) does NOT clear the cached signature
|
|
// or trigger a spurious re-register. Without this, a transient 5xx during
|
|
// the workspace sync loop would loop the daemon into re-registering forever.
|
|
func TestRefreshWorkspaceRuntimeProfiles_FetchErrorIsBestEffort(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{"company-codex": "/opt/bin/company-codex"})
|
|
profiles := []RuntimeProfile{{
|
|
ID: "prof-1", WorkspaceID: "ws-1", DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex", CommandName: "company-codex",
|
|
Visibility: "workspace", Enabled: true,
|
|
}}
|
|
// Server that returns 404 for the profiles route.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasSuffix(r.URL.Path, "/runtime-profiles") {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
d := freshDaemon(srv.URL)
|
|
d.profileLaunchSpecs = make(map[string]profileLaunchSpec)
|
|
knownSig := profileSetSignature(profiles)
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", []string{"rt-1"}, "", nil, nil)
|
|
d.workspaces["ws-1"].profileSetSig = knownSig
|
|
|
|
err := d.refreshWorkspaceRuntimeProfiles(context.Background(), "ws-1")
|
|
if err == nil {
|
|
t.Fatalf("404 must surface as an error so the caller can log it at debug")
|
|
}
|
|
|
|
// Cached signature must be preserved (no overwrite on transient failures).
|
|
d.mu.Lock()
|
|
gotSig := d.workspaces["ws-1"].profileSetSig
|
|
d.mu.Unlock()
|
|
if gotSig != knownSig {
|
|
t.Errorf("transient fetch error must not clobber cached sig; want %q got %q", knownSig, gotSig)
|
|
}
|
|
}
|