diff --git a/hub/src/store/machines.test.ts b/hub/src/store/machines.test.ts new file mode 100644 index 00000000..2677a815 --- /dev/null +++ b/hub/src/store/machines.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from './index' +import { mergeMachineMetadata } from './machines' + +describe('machine metadata backfill', () => { + it('merges incoming metadata over stored fields on re-registration', () => { + const store = new Store(':memory:') + const created = store.machines.getOrCreateMachine('machine-1', null, null, 'ns') + expect(created.metadata).toBeNull() + + const refreshed = store.machines.getOrCreateMachine( + 'machine-1', + { host: 'MacBook Pro', platform: 'darwin' }, + null, + 'ns' + ) + + expect(refreshed.metadata).toEqual({ host: 'MacBook Pro', platform: 'darwin' }) + expect(refreshed.metadataVersion).toBe(created.metadataVersion + 1) + }) + + it('preserves hub-side fields the CLI never sends', () => { + const store = new Store(':memory:') + store.machines.getOrCreateMachine('machine-1', { displayName: 'Workstation', host: 'old-host' }, null, 'ns') + + const refreshed = store.machines.getOrCreateMachine('machine-1', { host: 'new-host' }, null, 'ns') + + expect(refreshed.metadata).toEqual({ displayName: 'Workstation', host: 'new-host' }) + }) + + it('does not write when the merge changes nothing', () => { + const store = new Store(':memory:') + const created = store.machines.getOrCreateMachine('machine-1', { host: 'alpha' }, null, 'ns') + + const again = store.machines.getOrCreateMachine('machine-1', { host: 'alpha' }, null, 'ns') + + expect(again.metadataVersion).toBe(created.metadataVersion) + expect(again.updatedAt).toBe(created.updatedAt) + }) +}) + +describe('mergeMachineMetadata', () => { + it('returns undefined for non-object incoming metadata', () => { + expect(mergeMachineMetadata({ host: 'a' }, null)).toBeUndefined() + expect(mergeMachineMetadata({ host: 'a' }, 'host')).toBeUndefined() + expect(mergeMachineMetadata({ host: 'a' }, ['host'])).toBeUndefined() + }) + + it('returns undefined when the merge is a no-op', () => { + expect(mergeMachineMetadata({ host: 'a' }, { host: 'a' })).toBeUndefined() + }) +}) diff --git a/hub/src/store/machines.ts b/hub/src/store/machines.ts index 01d61cb7..a2d30506 100644 --- a/hub/src/store/machines.ts +++ b/hub/src/store/machines.ts @@ -34,6 +34,23 @@ function toStoredMachine(row: DbMachineRow): StoredMachine { } } +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// Rows created before the CLI reported full metadata (or by older versions) +// would keep missing fields like `host` forever — get-or-create returns the +// existing row untouched and every client hits it on startup. Merge incoming +// machine-owned fields over the stored ones so registration doubles as a +// refresh; hub-side fields the CLI never sends (e.g. displayName) survive. +// Returns undefined when the merge would not change anything. +export function mergeMachineMetadata(stored: unknown, incoming: unknown): Record | undefined { + if (!isPlainObject(incoming)) return undefined + const base = isPlainObject(stored) ? stored : {} + const merged = { ...base, ...incoming } + return JSON.stringify(merged) === JSON.stringify(base) ? undefined : merged +} + export function getOrCreateMachine( db: Database, id: string, @@ -47,6 +64,26 @@ export function getOrCreateMachine( if (stored.namespace !== namespace) { throw new Error('Machine namespace mismatch') } + const merged = mergeMachineMetadata(stored.metadata, metadata) + if (merged !== undefined) { + db.prepare(` + UPDATE machines + SET metadata = @metadata, + metadata_version = metadata_version + 1, + updated_at = @updated_at, + seq = seq + 1 + WHERE id = @id + `).run({ + metadata: JSON.stringify(merged), + updated_at: Date.now(), + id + }) + const row = getMachine(db, id) + if (!row) { + throw new Error('Failed to refresh machine metadata') + } + return row + } return stored } diff --git a/web/src/hooks/useMachineLabels.test.ts b/web/src/hooks/useMachineLabels.test.ts new file mode 100644 index 00000000..6f2d44ca --- /dev/null +++ b/web/src/hooks/useMachineLabels.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { renderHook } from '@testing-library/react' +import type { Machine } from '@/types/api' +import { getMachineTitle, useMachineLabels } from './useMachineLabels' + +function makeMachine(id: string, metadata: Machine['metadata']): Machine { + return { + id, + namespace: 'default', + seq: 1, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata, + metadataVersion: 1, + runnerState: null, + runnerStateVersion: 0, + } +} + +describe('getMachineTitle', () => { + it('prefers displayName, then host, then the id prefix', () => { + expect(getMachineTitle(makeMachine('abcdef123456', { displayName: 'Work', host: 'mac' } as Machine['metadata']))).toBe('Work') + expect(getMachineTitle(makeMachine('abcdef123456', { host: 'mac' } as Machine['metadata']))).toBe('mac') + expect(getMachineTitle(makeMachine('abcdef123456', null))).toBe('abcdef12') + }) +}) + +describe('useMachineLabels', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('returns live titles and caches them', () => { + const machines = [makeMachine('machine-1', { host: 'MacBook Pro' } as Machine['metadata'])] + const { result } = renderHook(() => useMachineLabels(machines)) + + expect(result.current['machine-1']).toBe('MacBook Pro') + expect(JSON.parse(window.localStorage.getItem('hapi-machine-labels')!)).toEqual({ 'machine-1': 'MacBook Pro' }) + }) + + it('keeps the cached label when the machine is absent from the list', () => { + window.localStorage.setItem('hapi-machine-labels', JSON.stringify({ 'gone-machine': 'MacBook Pro' })) + const { result } = renderHook(() => useMachineLabels([])) + + expect(result.current['gone-machine']).toBe('MacBook Pro') + }) + + it('prefers the live title over a stale cached one', () => { + window.localStorage.setItem('hapi-machine-labels', JSON.stringify({ 'machine-1': 'old-name' })) + const machines = [makeMachine('machine-1', { host: 'new-name' } as Machine['metadata'])] + const { result } = renderHook(() => useMachineLabels(machines)) + + expect(result.current['machine-1']).toBe('new-name') + }) +}) diff --git a/web/src/hooks/useMachineLabels.ts b/web/src/hooks/useMachineLabels.ts new file mode 100644 index 00000000..134c0682 --- /dev/null +++ b/web/src/hooks/useMachineLabels.ts @@ -0,0 +1,65 @@ +import { useEffect, useMemo } from 'react' +import type { Machine } from '@/types/api' + +export function getMachineTitle(machine: Machine): string { + if (machine.metadata?.displayName) return machine.metadata.displayName + if (machine.metadata?.host) return machine.metadata.host + return machine.id.slice(0, 8) +} + +const STORAGE_KEY = 'hapi-machine-labels' + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +function readCachedLabels(): Record { + if (!isBrowser()) return {} + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return {} + const parsed: unknown = JSON.parse(raw) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {} + const labels: Record = {} + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === 'string' && value.length > 0) { + labels[key] = value + } + } + return labels + } catch { + return {} + } +} + +function writeCachedLabels(labels: Record): void { + if (!isBrowser()) return + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(labels)) + } catch { + // Ignore storage errors + } +} + +/** + * Machine id → display label for the session list. Live data from the + * machines query wins, but labels are also cached in localStorage so a + * machine whose row is gone (reinstalled CLI, stale sessions) or whose + * query has not loaded yet keeps its last known name instead of falling + * back to a raw id prefix. + */ +export function useMachineLabels(machines: Machine[]): Record { + const labels = useMemo(() => { + const merged = readCachedLabels() + for (const machine of machines) { + merged[machine.id] = getMachineTitle(machine) + } + return merged + }, [machines]) + + useEffect(() => { + writeCachedLabels(labels) + }, [labels]) + + return labels +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 99375d06..5b6fe25e 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -27,6 +27,7 @@ import { isTelegramApp } from '@/hooks/useTelegram' import { useSidebarResize } from '@/hooks/useSidebarResize' import { useMessages } from '@/hooks/queries/useMessages' import { useMachines } from '@/hooks/queries/useMachines' +import { useMachineLabels } from '@/hooks/useMachineLabels' import { useSession } from '@/hooks/queries/useSession' import { useCursorChatStoreStatus } from '@/hooks/queries/useCursorChatStoreStatus' import { useSessions } from '@/hooks/queries/useSessions' @@ -44,7 +45,7 @@ import { inactiveSessionCanResume } from '@/lib/sessionResume' import { markSessionSeen } from '@/lib/sessionLastSeen' import { useSessionBrowserTitle } from '@/hooks/useSessionBrowserTitle' import { clearCodexImportedSession, markCodexSessionsImported } from '@/lib/codexImportedSessions' -import type { Machine, CodexDuplicateSessionGroup, CodexLocalSessionSummary } from '@/types/api' +import type { CodexDuplicateSessionGroup, CodexLocalSessionSummary } from '@/types/api' import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' import TerminalPage from '@/routes/sessions/terminal' @@ -182,12 +183,6 @@ function SettingsIcon(props: { className?: string }) { ) } -function getMachineTitle(machine: Machine): string { - if (machine.metadata?.displayName) return machine.metadata.displayName - if (machine.metadata?.host) return machine.metadata.host - return machine.id.slice(0, 8) -} - function SessionsPage() { const { api } = useAppContext() const navigate = useNavigate() @@ -232,13 +227,7 @@ function SessionsPage() { })() }, [addToast, refetch, t]) - const machineLabelsById = useMemo(() => { - const labels: Record = {} - for (const machine of machines) { - labels[machine.id] = getMachineTitle(machine) - } - return labels - }, [machines]) + const machineLabelsById = useMachineLabels(machines) const machinesById = useMemo(() => { const byId: Record = {} for (const machine of machines) {