Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
2bf62063e3 fix(daemon): machine-scoped daemon.id so CLI + desktop share one identity
Before this PR, `EnsureDaemonID(profile)` wrote to ~/.multica/profiles/
<profile>/daemon.id — meaning the same physical machine minted a different
UUID per profile. On any host running both the CLI-spawned daemon (default
profile) and the desktop-spawned daemon (profile derived from API host),
that produced two runtime rows per provider per workspace. The server-side
`legacy_daemon_ids` merge only covers hostname variants, not UUIDs, so the
rows just piled up.

Profile boundaries are about which backend/account the daemon is talking
to, not about the physical machine. Identity should be per-machine, token
should be per-profile.

Changes:

- `EnsureDaemonID` now always reads/writes ~/.multica/daemon.id regardless
  of the `profile` argument. The argument is retained for migration-only
  use (see promotion below).
- Migration path: when the canonical file is missing and the requested
  profile has a pre-change per-profile daemon.id, promote that UUID in
  place so a user who only ever ran under a named profile keeps the same
  identity instead of minting a fresh UUID and round-tripping a merge.
- New `LegacyDaemonUUIDs()` scans ~/.multica/profiles/*/daemon.id and
  returns every UUID that survives parsing. `config.go` now appends those
  to the daemon's `legacy_daemon_ids` payload, so any runtime rows
  previously registered under a per-profile UUID (on any backend) get
  merged into the canonical machine UUID at register time.

Tests replace the `ProfileIsolated` assertion with `SharedAcrossProfiles`
and add coverage for promotion, UUID scanning (including skipping corrupt
files), and the empty-profiles-dir fast path.
2026-04-17 15:25:38 +08:00
3 changed files with 219 additions and 31 deletions

View File

@@ -209,8 +209,18 @@ func LoadConfig(overrides Overrides) (Config, error) {
// server uses these at register time to merge any pre-UUID runtime rows
// for this machine into the new UUID-keyed row and delete the stale ones.
legacyDaemonIDs := LegacyDaemonIDs(host, profile)
// Pre-change (#1220) daemon identity was stored per profile, which means
// the same machine could end up with multiple leftover daemon.id files
// — e.g. ~/.multica/daemon.id (default) plus ~/.multica/profiles/<x>/
// daemon.id. Surface those UUIDs so the server can merge their runtime
// rows into the canonical machine UUID. Fatal-free: a broken profiles
// dir shouldn't block startup.
if uuids, err := LegacyDaemonUUIDs(); err == nil {
legacyDaemonIDs = append(legacyDaemonIDs, uuids...)
}
// Strip anything that collides with the resolved daemon_id (e.g. when
// the user explicitly pins MULTICA_DAEMON_ID=<hostname>).
// the user explicitly pins MULTICA_DAEMON_ID=<hostname>, or when the
// canonical id was itself promoted from a pre-change profile file).
legacyDaemonIDs = filterLegacyIDs(legacyDaemonIDs, daemonID)
deviceName := envOrDefault("MULTICA_DAEMON_DEVICE_NAME", host)

View File

@@ -11,23 +11,33 @@ import (
"github.com/multica-ai/multica/server/internal/cli"
)
// daemonIDFileName is the per-profile file that stores this machine's stable
// daemon identifier. Once created, the UUID inside is the daemon's identity
// forever — hostname changes, .local suffix drift, profile switches and
// system renames no longer mint a new identity.
// daemonIDFileName is the file that stores this machine's stable daemon
// identifier. Once created, the UUID inside is the daemon's identity 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:
// it to disk on first call. Identity is machine-scoped: every profile on the
// same machine shares one UUID stored at `~/.multica/daemon.id`. Profile
// boundaries are about which backend/account a daemon is talking to, not
// about the physical machine's identity, so a single host running both the
// CLI-spawned daemon and the desktop-spawned daemon (or toggling profiles)
// registers as one runtime everywhere rather than N.
//
// default profile → ~/.multica/daemon.id
// named profile → ~/.multica/profiles/<name>/daemon.id
// The `profile` argument is retained purely for one-time migration: if the
// canonical file does not yet exist and the current profile has a leftover
// per-profile daemon.id from the pre-#1220 layout, promote it in place so a
// user who previously ran the daemon under a named profile keeps the same
// UUID instead of a fresh mint + merge round-trip. Any OTHER leftover
// per-profile daemon.id files are surfaced separately via LegacyDaemonUUIDs
// so the server can merge their runtime rows into the canonical row at
// register time.
//
// 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)
dir, err := cli.ProfileDir("")
if err != nil {
return "", err
}
@@ -47,35 +57,78 @@ func EnsureDaemonID(profile string) (string, error) {
return "", fmt.Errorf("create profile directory: %w", err)
}
// One-time promotion from pre-change per-profile layout.
if promoted, ok := promoteProfileDaemonID(profile, path); ok {
return promoted, nil
}
id, err := uuid.NewV7()
if err != nil {
return "", fmt.Errorf("generate daemon id: %w", err)
}
tmp, err := os.CreateTemp(dir, ".daemon-*.id.tmp")
if err := writeDaemonIDFile(path, id.String()); err != nil {
return "", err
}
return id.String(), nil
}
// promoteProfileDaemonID copies a pre-change per-profile daemon.id into the
// canonical machine-scoped location. Returns the promoted UUID and true on
// success; returns "", false when there is nothing valid to promote (empty
// profile, missing/corrupt source file, any I/O failure). Promotion is a
// best-effort migration — a failure here falls through to fresh UUID mint.
func promoteProfileDaemonID(profile, targetPath string) (string, bool) {
if profile == "" {
return "", false
}
profileDir, err := cli.ProfileDir(profile)
if err != nil {
return "", fmt.Errorf("create temp daemon id file: %w", err)
return "", false
}
src := filepath.Join(profileDir, daemonIDFileName)
data, err := os.ReadFile(src)
if err != nil {
return "", false
}
id := strings.TrimSpace(string(data))
if _, err := uuid.Parse(id); err != nil {
return "", false
}
if err := writeDaemonIDFile(targetPath, id); err != nil {
return "", false
}
return id, true
}
// writeDaemonIDFile writes the UUID to path atomically with 0600 mode.
func writeDaemonIDFile(path, id string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create parent directory: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".daemon-*.id.tmp")
if err != nil {
return fmt.Errorf("create temp daemon id file: %w", err)
}
tmpPath := tmp.Name()
if _, err := tmp.WriteString(id.String() + "\n"); err != nil {
if _, err := tmp.WriteString(id + "\n"); err != nil {
tmp.Close()
os.Remove(tmpPath)
return "", fmt.Errorf("write temp daemon id file: %w", err)
return fmt.Errorf("write temp daemon id file: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("close temp daemon id file: %w", err)
return fmt.Errorf("close temp daemon id file: %w", err)
}
if err := os.Chmod(tmpPath, 0o600); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("chmod temp daemon id file: %w", err)
return fmt.Errorf("chmod temp daemon id file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("rename daemon id file: %w", err)
return fmt.Errorf("rename daemon id file: %w", err)
}
return id.String(), nil
return nil
}
// LegacyDaemonIDs returns the set of daemon_id values this machine may have
@@ -129,6 +182,49 @@ func LegacyDaemonIDs(hostname, profile string) []string {
return out
}
// LegacyDaemonUUIDs scans `~/.multica/profiles/*/daemon.id` and returns every
// UUID that survives parsing. These are identities that were minted per
// profile before daemon identity became machine-scoped; runtime rows
// registered under them — potentially on multiple backends (prod/dev/self-
// host) — need to be merged into the canonical machine UUID. The list is
// safe to emit to every backend: a UUID that was never registered there
// simply matches nothing in the server's merge lookup.
//
// Errors reading individual profile files are swallowed: a bad file
// shouldn't block daemon startup. A missing profiles directory returns
// (nil, nil) — that's the common case on a clean install.
func LegacyDaemonUUIDs() ([]string, error) {
root, err := cli.ProfileDir("")
if err != nil {
return nil, err
}
profilesDir := filepath.Join(root, "profiles")
entries, err := os.ReadDir(profilesDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("read profiles dir: %w", err)
}
var ids []string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
data, err := os.ReadFile(filepath.Join(profilesDir, entry.Name(), daemonIDFileName))
if err != nil {
continue
}
id := strings.TrimSpace(string(data))
if _, err := uuid.Parse(id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, nil
}
// filterLegacyIDs removes any entry equal to current (e.g. when the user
// explicitly pins MULTICA_DAEMON_ID to the hostname itself, there's nothing
// to migrate — the row is already keyed on the current id).

View File

@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
@@ -40,7 +41,7 @@ func TestEnsureDaemonID_Persists(t *testing.T) {
}
}
func TestEnsureDaemonID_ProfileIsolated(t *testing.T) {
func TestEnsureDaemonID_SharedAcrossProfiles(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
@@ -52,12 +53,50 @@ func TestEnsureDaemonID_ProfileIsolated(t *testing.T) {
if err != nil {
t.Fatalf("staging profile: %v", err)
}
if defaultID == stagingID {
t.Fatalf("profiles shared the same daemon id: %s", defaultID)
if defaultID != stagingID {
t.Fatalf("profiles should share one machine id, got default=%s staging=%s", defaultID, stagingID)
}
if _, err := os.Stat(filepath.Join(home, ".multica", "profiles", "staging", "daemon.id")); err != nil {
t.Fatalf("profile-scoped daemon.id missing: %v", err)
// Profile-scoped file must not be created under the new layout — the
// only source of truth is ~/.multica/daemon.id.
profileFile := filepath.Join(home, ".multica", "profiles", "staging", "daemon.id")
if _, err := os.Stat(profileFile); !os.IsNotExist(err) {
t.Fatalf("profile-scoped daemon.id should not be created, stat err: %v", err)
}
}
func TestEnsureDaemonID_PromotesPreChangeProfileFile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
// Seed a per-profile daemon.id the way pre-#1220 daemons laid it out.
legacyID := uuid.Must(uuid.NewV7()).String()
profileDir := filepath.Join(home, ".multica", "profiles", "staging")
if err := os.MkdirAll(profileDir, 0o755); err != nil {
t.Fatalf("mkdir profile: %v", err)
}
if err := os.WriteFile(filepath.Join(profileDir, "daemon.id"), []byte(legacyID+"\n"), 0o600); err != nil {
t.Fatalf("seed legacy id: %v", err)
}
// First call on the post-change daemon with the matching profile must
// reuse the pre-change UUID so existing runtime rows continue to match
// without needing a merge round-trip.
got, err := EnsureDaemonID("staging")
if err != nil {
t.Fatalf("EnsureDaemonID: %v", err)
}
if got != legacyID {
t.Fatalf("expected promoted UUID %s, got %s", legacyID, got)
}
// The canonical file now holds that same UUID.
data, err := os.ReadFile(filepath.Join(home, ".multica", "daemon.id"))
if err != nil {
t.Fatalf("read canonical file: %v", err)
}
if strings.TrimSpace(string(data)) != legacyID {
t.Fatalf("canonical file %q != promoted %q", data, legacyID)
}
}
@@ -88,6 +127,56 @@ func TestEnsureDaemonID_RegeneratesCorruptFile(t *testing.T) {
}
}
func TestLegacyDaemonUUIDs_ScansProfileDirs(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
uuidA := uuid.Must(uuid.NewV7()).String()
uuidB := uuid.Must(uuid.NewV7()).String()
for name, id := range map[string]string{"prod": uuidA, "desktop-multica": uuidB} {
dir := filepath.Join(home, ".multica", "profiles", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", name, err)
}
if err := os.WriteFile(filepath.Join(dir, "daemon.id"), []byte(id+"\n"), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
// A profile directory with a corrupt file must be skipped, not fail.
corruptDir := filepath.Join(home, ".multica", "profiles", "corrupt")
if err := os.MkdirAll(corruptDir, 0o755); err != nil {
t.Fatalf("mkdir corrupt: %v", err)
}
if err := os.WriteFile(filepath.Join(corruptDir, "daemon.id"), []byte("not-a-uuid"), 0o600); err != nil {
t.Fatalf("seed corrupt: %v", err)
}
got, err := LegacyDaemonUUIDs()
if err != nil {
t.Fatalf("LegacyDaemonUUIDs: %v", err)
}
sort.Strings(got)
want := []string{uuidA, uuidB}
sort.Strings(want)
if !reflect.DeepEqual(got, want) {
t.Fatalf("LegacyDaemonUUIDs = %v, want %v", got, want)
}
}
func TestLegacyDaemonUUIDs_MissingProfilesDirIsNil(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
ids, err := LegacyDaemonUUIDs()
if err != nil {
t.Fatalf("LegacyDaemonUUIDs: %v", err)
}
if ids != nil {
t.Fatalf("expected nil on missing profiles dir, got %v", ids)
}
}
func TestLegacyDaemonIDs(t *testing.T) {
cases := []struct {
name string
@@ -96,15 +185,11 @@ func TestLegacyDaemonIDs(t *testing.T) {
want []string
}{
{
// Bare hostname now — but the DB may still hold the previously
// registered `.local` variant, so we must emit both.
name: "plain hostname, no profile",
hostname: "MacBook-Pro",
want: []string{"MacBook-Pro", "MacBook-Pro.local"},
},
{
// Dot-local hostname now — the stripped variant may be what the
// DB holds from a prior registration where .local was absent.
name: "dot-local hostname, no profile",
hostname: "MacBook-Pro.local",
want: []string{"MacBook-Pro", "MacBook-Pro.local"},
@@ -137,9 +222,6 @@ func TestLegacyDaemonIDs(t *testing.T) {
want: nil,
},
{
// Case drift is handled on the server side (LOWER=LOWER match).
// We still emit the hostname in its current casing here; the SQL
// query normalizes both sides.
name: "mixed case hostname preserved as-is",
hostname: "Jiayuans-MacBook-Pro.local",
want: []string{