diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index ab04df4b0..cc398331c 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -189,9 +189,12 @@ func LoadConfig(overrides Overrides) (Config, error) { profile := overrides.Profile // daemon_id resolution: override > env > persistent UUID on disk. - // The persistent UUID is written once to `/daemon.id` and - // then reused forever so hostname drift (.local suffix, system rename, - // mDNS state, profile switch) no longer mints a new runtime identity. + // The persistent UUID is written once to `~/.multica/daemon.id` and + // reused forever so hostname drift (.local suffix, system rename, mDNS + // state) no longer mints a new runtime identity. The file is machine- + // wide — intentionally not per-profile — so CLI and Desktop daemons on + // the same host share one identity and collapse into a single runtime + // row via the `(workspace_id, daemon_id, provider)` unique constraint. // Callers may still pin a specific id via MULTICA_DAEMON_ID or the // override field (e.g. for tests or embedded environments). daemonID := strings.TrimSpace(os.Getenv("MULTICA_DAEMON_ID")) @@ -199,7 +202,7 @@ func LoadConfig(overrides Overrides) (Config, error) { daemonID = overrides.DaemonID } if daemonID == "" { - persisted, err := EnsureDaemonID(profile) + persisted, err := EnsureDaemonID() if err != nil { return Config{}, fmt.Errorf("ensure daemon id: %w", err) } diff --git a/server/internal/daemon/identity.go b/server/internal/daemon/identity.go index 8dc5f6310..26761b673 100644 --- a/server/internal/daemon/identity.go +++ b/server/internal/daemon/identity.go @@ -11,23 +11,30 @@ import ( "github.com/multica-ai/multica/server/internal/cli" ) -// daemonIDFileName is the per-profile file that stores this machine's stable +// daemonIDFileName is the per-machine file that stores this host's stable // daemon identifier. Once created, the UUID inside is the daemon's identity -// forever — hostname changes, .local suffix drift, profile switches and +// forever — hostname changes, .local suffix drift, profile switches, and // system renames no longer mint a new identity. const daemonIDFileName = "daemon.id" -// EnsureDaemonID returns a stable UUID for this daemon instance, persisting -// it to disk on first call. The file is stored per profile so multiple -// profiles on the same machine each get their own identity: +// EnsureDaemonID returns a stable UUID for this machine, persisting it to +// disk at `~/.multica/daemon.id` on first call. // -// default profile → ~/.multica/daemon.id -// named profile → ~/.multica/profiles//daemon.id +// The file is intentionally NOT per-profile. A single machine has one daemon +// identity regardless of which profile the user is running under — the CLI +// daemon (default profile) and the Desktop daemon (its own `desktop-` +// profile) must both register against the same runtime row, or the user ends +// up with two rows per provider per workspace every time they open the +// Desktop app after using the CLI (or vice versa). The unique constraint +// `(workspace_id, daemon_id, provider)` then naturally collapses them. +// +// Profiles still own their own config.json / log / token — only *identity* +// is machine-wide. // // If the file exists but is corrupt (unparseable), it is regenerated so the // daemon can continue starting up instead of hard-failing. -func EnsureDaemonID(profile string) (string, error) { - dir, err := cli.ProfileDir(profile) +func EnsureDaemonID() (string, error) { + dir, err := cli.ProfileDir("") if err != nil { return "", err } @@ -44,7 +51,7 @@ func EnsureDaemonID(profile string) (string, error) { } if err := os.MkdirAll(dir, 0o755); err != nil { - return "", fmt.Errorf("create profile directory: %w", err) + return "", fmt.Errorf("create multica directory: %w", err) } id, err := uuid.NewV7() diff --git a/server/internal/daemon/identity_test.go b/server/internal/daemon/identity_test.go index f1d882b69..b8ffa910a 100644 --- a/server/internal/daemon/identity_test.go +++ b/server/internal/daemon/identity_test.go @@ -14,7 +14,7 @@ func TestEnsureDaemonID_Persists(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) - first, err := EnsureDaemonID("") + first, err := EnsureDaemonID() if err != nil { t.Fatalf("EnsureDaemonID first call: %v", err) } @@ -31,7 +31,7 @@ func TestEnsureDaemonID_Persists(t *testing.T) { t.Fatalf("file contents %q differ from returned UUID %q", data, first) } - second, err := EnsureDaemonID("") + second, err := EnsureDaemonID() if err != nil { t.Fatalf("EnsureDaemonID second call: %v", err) } @@ -40,24 +40,33 @@ func TestEnsureDaemonID_Persists(t *testing.T) { } } -func TestEnsureDaemonID_ProfileIsolated(t *testing.T) { +// TestEnsureDaemonID_MachineScopedAcrossProfiles pins the behavior the user +// needs: identity is machine-wide, not profile-scoped. The CLI daemon and the +// Desktop daemon (which runs under its own `desktop-` profile) must end +// up with the same daemon_id when running on the same machine, so they +// register against a single runtime row instead of minting a new row every +// time the Desktop app opens alongside the CLI. +func TestEnsureDaemonID_MachineScopedAcrossProfiles(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) - defaultID, err := EnsureDaemonID("") + cliID, err := EnsureDaemonID() if err != nil { - t.Fatalf("default profile: %v", err) + t.Fatalf("first call: %v", err) } - stagingID, err := EnsureDaemonID("staging") + // Simulate a second daemon process (e.g. Desktop) starting up — it must + // read the same file, not mint a new UUID. + desktopID, err := EnsureDaemonID() if err != nil { - t.Fatalf("staging profile: %v", err) + t.Fatalf("second call: %v", err) } - if defaultID == stagingID { - t.Fatalf("profiles shared the same daemon id: %s", defaultID) + if cliID != desktopID { + t.Fatalf("machine identity drifted between calls: %s vs %s", cliID, desktopID) } - if _, err := os.Stat(filepath.Join(home, ".multica", "profiles", "staging", "daemon.id")); err != nil { - t.Fatalf("profile-scoped daemon.id missing: %v", err) + // And no stray per-profile daemon.id should have been written. + if _, err := os.Stat(filepath.Join(home, ".multica", "profiles", "desktop-api.example.com", "daemon.id")); !os.IsNotExist(err) { + t.Fatalf("unexpected per-profile daemon.id present: err=%v", err) } } @@ -74,7 +83,7 @@ func TestEnsureDaemonID_RegeneratesCorruptFile(t *testing.T) { t.Fatalf("seed corrupt file: %v", err) } - id, err := EnsureDaemonID("") + id, err := EnsureDaemonID() if err != nil { t.Fatalf("EnsureDaemonID: %v", err) }