mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 10:32:36 +02:00
Compare commits
1 Commits
agent/lamb
...
fix/daemon
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf62063e3 |
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user