fix(daemon): machine-scoped daemon.id so CLI and Desktop share one identity

Dev found that starting Desktop after CLI (or vice versa) minted a second
runtime row per provider instead of re-binding to the existing one. Root
cause: EnsureDaemonID wrote under the profile directory, so the CLI daemon
(default profile) and the Desktop daemon (its own `desktop-<host>` profile)
each generated their own UUID even though they're the same machine.

daemon.id now lives at `~/.multica/daemon.id` only — machine-scoped, not
profile-scoped. Profiles still own their own config.json / log / token;
only identity is shared. With one id per machine, the existing unique
constraint `(workspace_id, daemon_id, provider)` naturally collapses
CLI+Desktop into a single runtime row per provider.

Test reversal: replaced TestEnsureDaemonID_ProfileIsolated with
TestEnsureDaemonID_MachineScopedAcrossProfiles, which pins the new
invariant (two sequential calls return the same UUID; no per-profile
daemon.id is written).
This commit is contained in:
Jiang Bohan
2026-04-17 15:27:22 +08:00
parent 2c9f1ac6c1
commit b62c7b83ae
3 changed files with 45 additions and 26 deletions

View File

@@ -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 `<profile-dir>/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)
}

View File

@@ -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/<name>/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-<host>`
// 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()

View File

@@ -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-<host>` 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)
}