mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user