From f53fe324a6f0821a0a84460c3d26ceec3efd62f2 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Sat, 18 Apr 2026 00:58:41 +0200 Subject: [PATCH] fix(daemon): persist machine identity in machine.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move machine.{id,name} to its own file ~/.agentage/machine.json so deleting or regenerating config.json doesn't mint a fresh UUID and create a ghost machine in the hub dashboard. Resolution order at loadConfig: 1. machine.json if present (authoritative) 2. legacy machine block from config.json → migrated into machine.json 3. freshly minted UUID + hostname (written to machine.json) saveConfig now writes machine.json alongside config.json; the machine block stays mirrored in config.json for back-compat reads. Repro that motivated this: user deletes config.json to adopt the 0.18 shape, daemon re-registers with a new UUID, the old machine sticks around in Supabase as a ghost. --- src/daemon/config.test.ts | 78 ++++++++++++++++++++++++++++++++++++++- src/daemon/config.ts | 74 +++++++++++++++++++++++++++++-------- 2 files changed, 136 insertions(+), 16 deletions(-) diff --git a/src/daemon/config.test.ts b/src/daemon/config.test.ts index 4c7f3c9..a58f475 100644 --- a/src/daemon/config.test.ts +++ b/src/daemon/config.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -31,6 +31,82 @@ describe('config', () => { expect(config.projects.default).toBe(join(homedir(), 'projects')); expect(config.projects.additional).toEqual([]); expect(existsSync(join(testDir, 'config.json'))).toBe(true); + expect(existsSync(join(testDir, 'machine.json'))).toBe(true); + }); + + it('persists machine identity across config.json deletion', async () => { + const { loadConfig } = await import('./config.js'); + const first = loadConfig(); + const originalId = first.machine.id; + const originalName = first.machine.name; + + // Simulate user deleting (or regenerating) config.json — machine.json remains + unlinkSync(join(testDir, 'config.json')); + expect(existsSync(join(testDir, 'machine.json'))).toBe(true); + + // Second load: config.json regenerated, identity preserved from machine.json + const second = loadConfig(); + expect(second.machine.id).toBe(originalId); + expect(second.machine.name).toBe(originalName); + expect(existsSync(join(testDir, 'config.json'))).toBe(true); + }); + + it('migrates legacy machine block from config.json into machine.json', async () => { + const legacyId = '260d7044-b229-4afb-bbf3-40af745c3926'; + writeFileSync( + join(testDir, 'config.json'), + JSON.stringify({ + machine: { id: legacyId, name: 'legacy-host' }, + daemon: { port: 4243 }, + agents: { default: '/a', additional: [] }, + projects: { default: '/p', additional: [] }, + sync: { events: {} }, + }) + '\n' + ); + + const { loadConfig } = await import('./config.js'); + const config = loadConfig(); + + expect(config.machine.id).toBe(legacyId); + expect(config.machine.name).toBe('legacy-host'); + + const machineJson = JSON.parse(readFileSync(join(testDir, 'machine.json'), 'utf-8')); + expect(machineJson.id).toBe(legacyId); + expect(machineJson.name).toBe('legacy-host'); + }); + + it('machine.json is authoritative over config.json when both exist', async () => { + writeFileSync( + join(testDir, 'machine.json'), + JSON.stringify({ id: 'from-machine-json', name: 'auth-host' }) + '\n' + ); + writeFileSync( + join(testDir, 'config.json'), + JSON.stringify({ + machine: { id: 'from-config-json', name: 'stale-host' }, + daemon: { port: 4243 }, + agents: { default: '/a', additional: [] }, + projects: { default: '/p', additional: [] }, + sync: { events: {} }, + }) + '\n' + ); + + const { loadConfig } = await import('./config.js'); + const config = loadConfig(); + + expect(config.machine.id).toBe('from-machine-json'); + expect(config.machine.name).toBe('auth-host'); + }); + + it('saveConfig writes machine.json too', async () => { + const { loadConfig, saveConfig } = await import('./config.js'); + const config = loadConfig(); + config.machine.name = 'renamed'; + saveConfig(config); + + const machineJson = JSON.parse(readFileSync(join(testDir, 'machine.json'), 'utf-8')); + expect(machineJson.name).toBe('renamed'); + expect(machineJson.id).toBe(config.machine.id); }); it('generates machine ID as UUID v4', async () => { diff --git a/src/daemon/config.ts b/src/daemon/config.ts index 6eb3cb9..9588734 100644 --- a/src/daemon/config.ts +++ b/src/daemon/config.ts @@ -19,11 +19,13 @@ export interface DirConfig { additional: string[]; } +export interface MachineIdentity { + id: string; + name: string; +} + export interface DaemonConfig { - machine: { - id: string; - name: string; - }; + machine: MachineIdentity; daemon: { port: number; }; @@ -58,11 +60,47 @@ export const getProjectsDirs = (config: DaemonConfig): string[] => { return Array.from(new Set(all)); }; -const createDefaultConfig = (): DaemonConfig => ({ - machine: { - id: randomUUID(), - name: hostname(), - }, +const getMachinePath = (): string => join(getConfigDir(), 'machine.json'); + +const readMachineFile = (): MachineIdentity | undefined => { + const path = getMachinePath(); + if (!existsSync(path)) return undefined; + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial; + if (typeof parsed.id === 'string' && typeof parsed.name === 'string') { + return { id: parsed.id, name: parsed.name }; + } + } catch { + // fall through + } + return undefined; +}; + +const writeMachineFile = (machine: MachineIdentity): void => { + const configDir = getConfigDir(); + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }); + } + writeFileSync(getMachinePath(), JSON.stringify(machine, null, 2) + '\n', 'utf-8'); +}; + +/** + * Resolve machine identity with this priority: + * 1. machine.json (authoritative — survives config.json regen) + * 2. legacy config.json `machine` block (migrated into machine.json) + * 3. freshly minted identity (UUID + hostname) + */ +const resolveMachine = (fromLegacyConfig: MachineIdentity | undefined): MachineIdentity => { + const fromFile = readMachineFile(); + if (fromFile) return fromFile; + + const adopted = fromLegacyConfig ?? { id: randomUUID(), name: hostname() }; + writeMachineFile(adopted); + return adopted; +}; + +const createDefaultConfig = (machine: MachineIdentity): DaemonConfig => ({ + machine, daemon: { port: 4243, }, @@ -91,13 +129,18 @@ const createDefaultConfig = (): DaemonConfig => ({ export const loadConfig = (): DaemonConfig => { const configPath = join(getConfigDir(), 'config.json'); - let config: DaemonConfig; - + let rawConfig: Partial | undefined; if (existsSync(configPath)) { - const raw = readFileSync(configPath, 'utf-8'); - config = JSON.parse(raw) as DaemonConfig; - } else { - config = createDefaultConfig(); + rawConfig = JSON.parse(readFileSync(configPath, 'utf-8')) as Partial; + } + + const machine = resolveMachine(rawConfig?.machine); + + const config: DaemonConfig = rawConfig + ? ({ ...rawConfig, machine } as DaemonConfig) + : createDefaultConfig(machine); + + if (!existsSync(configPath)) { saveConfig(config); } @@ -124,6 +167,7 @@ export const saveConfig = (config: DaemonConfig): void => { if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }); } + writeMachineFile(config.machine); const configPath = join(configDir, 'config.json'); writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); };