feat(device): device registry TRPC (register / list / update / remove) (#15299)

Server-side foundation for the device registry. Builds on the `devices` table
(already on canary) so devices persist beyond the gateway's in-memory WS
sessions and stay visible/bindable while offline.

- new DeviceModel: register upserts on (userId, deviceId) and only refreshes
  machine-reported fields + lastSeenAt, so user-owned friendlyName / defaultCwd
  / recentCwds survive re-registration
- device.* router gains register / updateDevice / removeDevice (DB row only, no
  OIDC token revocation); listDevices is rewritten as a DB ∪ online union so
  offline devices stay listed and not-yet-registered online devices surface as
  transient entries
- HeteroDeviceSwitcher adapts to the richer listDevices shape (null-safe
  platform, prefers friendlyName)

Desktop / CLI auto-registration ships in a follow-up PR that depends on this.

Part of LOBE-9572. Closes LOBE-9575.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-05-28 21:51:35 +08:00
committed by GitHub
parent 6d94635631
commit 671b2527b8
4 changed files with 337 additions and 6 deletions
@@ -0,0 +1,148 @@
// @vitest-environment node
import { and, eq } from 'drizzle-orm';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getTestDB } from '../../core/getTestDB';
import { devices, users } from '../../schemas';
import type { LobeChatDatabase } from '../../type';
import { DeviceModel } from '../device';
const serverDB: LobeChatDatabase = await getTestDB();
const userId = 'device-model-test-user-id';
const deviceModel = new DeviceModel(serverDB, userId);
beforeEach(async () => {
await serverDB.delete(users);
await serverDB.insert(users).values([{ id: userId }, { id: 'device-model-other-user' }]);
});
afterEach(async () => {
await serverDB.delete(users).where(eq(users.id, userId));
await serverDB.delete(devices).where(eq(devices.userId, userId));
});
describe('DeviceModel', () => {
describe('register', () => {
it('should insert a new device', async () => {
const result = await deviceModel.register({
deviceId: 'dev-1',
hostname: 'My-Mac.local',
identitySource: 'machine-id',
platform: 'darwin',
});
expect(result.id).toBeDefined();
expect(result).toMatchObject({
deviceId: 'dev-1',
hostname: 'My-Mac.local',
identitySource: 'machine-id',
platform: 'darwin',
userId,
});
});
it('should upsert on (userId, deviceId) and refresh machine fields', async () => {
await deviceModel.register({
deviceId: 'dev-1',
hostname: 'old-host',
identitySource: 'fallback',
platform: 'linux',
});
await deviceModel.register({
deviceId: 'dev-1',
hostname: 'new-host',
identitySource: 'machine-id',
platform: 'darwin',
});
const rows = await serverDB.query.devices.findMany({
where: and(eq(devices.userId, userId), eq(devices.deviceId, 'dev-1')),
});
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
hostname: 'new-host',
identitySource: 'machine-id',
platform: 'darwin',
});
});
it('should NOT overwrite user-owned fields on re-register', async () => {
await deviceModel.register({
deviceId: 'dev-1',
hostname: 'host',
identitySource: 'machine-id',
platform: 'darwin',
});
await deviceModel.update('dev-1', {
defaultCwd: '/Users/me/work',
friendlyName: 'My Work Mac',
recentCwds: ['/Users/me/work', '/Users/me/tmp'],
});
// Re-register (e.g. user logs in again / reconnects)
await deviceModel.register({
deviceId: 'dev-1',
hostname: 'host',
identitySource: 'machine-id',
platform: 'darwin',
});
const row = await deviceModel.findByDeviceId('dev-1');
expect(row).toMatchObject({
defaultCwd: '/Users/me/work',
friendlyName: 'My Work Mac',
recentCwds: ['/Users/me/work', '/Users/me/tmp'],
});
});
});
describe('query', () => {
it('should return only the current user devices, newest lastSeen first', async () => {
await deviceModel.register({ deviceId: 'dev-old', identitySource: 'machine-id' });
await deviceModel.register({ deviceId: 'dev-new', identitySource: 'machine-id' });
// a device owned by another user must not leak
await new DeviceModel(serverDB, 'device-model-other-user').register({
deviceId: 'other-dev',
identitySource: 'machine-id',
});
const list = await deviceModel.query();
expect(list.map((d) => d.deviceId)).toEqual(['dev-new', 'dev-old']);
});
});
describe('update', () => {
it('should update user-editable fields', async () => {
await deviceModel.register({ deviceId: 'dev-1', identitySource: 'machine-id' });
await deviceModel.update('dev-1', { friendlyName: 'Renamed' });
const row = await deviceModel.findByDeviceId('dev-1');
expect(row?.friendlyName).toBe('Renamed');
});
it('should not affect another user device with the same deviceId', async () => {
await deviceModel.register({ deviceId: 'shared-id', identitySource: 'machine-id' });
const other = new DeviceModel(serverDB, 'device-model-other-user');
await other.register({ deviceId: 'shared-id', identitySource: 'machine-id' });
await deviceModel.update('shared-id', { friendlyName: 'Mine' });
const otherRow = await other.findByDeviceId('shared-id');
expect(otherRow?.friendlyName).toBeNull();
});
});
describe('delete', () => {
it('should remove the row', async () => {
await deviceModel.register({ deviceId: 'dev-1', identitySource: 'machine-id' });
await deviceModel.delete('dev-1');
const row = await deviceModel.findByDeviceId('dev-1');
expect(row).toBeUndefined();
});
});
});
+87
View File
@@ -0,0 +1,87 @@
import { and, desc, eq } from 'drizzle-orm';
import type { DeviceItem } from '../schemas';
import { devices } from '../schemas';
import type { LobeChatDatabase } from '../type';
export interface RegisterDeviceParams {
deviceId: string;
hostname?: string | null;
identitySource: string;
platform?: string | null;
}
/** Columns the user owns — never overwritten by an auto-register upsert. */
export interface UpdateDeviceParams {
defaultCwd?: string | null;
friendlyName?: string | null;
recentCwds?: string[];
}
export class DeviceModel {
private userId: string;
private db: LobeChatDatabase;
constructor(db: LobeChatDatabase, userId: string) {
this.userId = userId;
this.db = db;
}
/**
* Auto-register from desktop/CLI. Upserts on the (userId, deviceId) unique
* index. On conflict only the machine-reported fields + lastSeenAt are
* refreshed — friendlyName / defaultCwd / recentCwds are user-owned and
* must survive re-registration.
*/
register = async (params: RegisterDeviceParams) => {
const now = new Date();
const [result] = await this.db
.insert(devices)
.values({
deviceId: params.deviceId,
hostname: params.hostname,
identitySource: params.identitySource,
lastSeenAt: now,
platform: params.platform,
userId: this.userId,
})
.onConflictDoUpdate({
set: {
hostname: params.hostname,
identitySource: params.identitySource,
lastSeenAt: now,
platform: params.platform,
},
target: [devices.userId, devices.deviceId],
})
.returning();
return result;
};
query = async (): Promise<DeviceItem[]> => {
return this.db.query.devices.findMany({
orderBy: [desc(devices.lastSeenAt)],
where: eq(devices.userId, this.userId),
});
};
findByDeviceId = async (deviceId: string) => {
return this.db.query.devices.findFirst({
where: and(eq(devices.userId, this.userId), eq(devices.deviceId, deviceId)),
});
};
update = async (deviceId: string, value: UpdateDeviceParams) => {
return this.db
.update(devices)
.set({ ...value, updatedAt: new Date() })
.where(and(eq(devices.userId, this.userId), eq(devices.deviceId, deviceId)));
};
delete = async (deviceId: string) => {
return this.db
.delete(devices)
.where(and(eq(devices.userId, this.userId), eq(devices.deviceId, deviceId)));
};
}
@@ -200,7 +200,7 @@ const OptionRow = memo<OptionRowProps>(({ active, desc, disabled, icon, label, o
OptionRow.displayName = 'HeteroDeviceSwitcher.OptionRow';
const getDeviceIcon = (platform: string | undefined, size = 14): ReactNode => {
const getDeviceIcon = (platform: string | null | undefined, size = 14): ReactNode => {
switch (platform) {
case 'darwin': {
return <SiApple color="currentColor" size={size} />;
@@ -278,7 +278,10 @@ const HeteroDeviceSwitcher = memo<HeteroDeviceSwitcherProps>(({ agentId }) => {
chipLabel = t('heteroAgent.executionTarget.local');
} else if (executionTarget === 'device') {
chipIcon = getDeviceIcon(boundDevice?.platform);
chipLabel = boundDevice?.hostname ?? t('heteroAgent.executionTarget.unknownDevice');
chipLabel =
boundDevice?.friendlyName ??
boundDevice?.hostname ??
t('heteroAgent.executionTarget.unknownDevice');
}
const isActive = (target: HeteroExecutionTarget, deviceId?: string) => {
@@ -318,7 +321,7 @@ const HeteroDeviceSwitcher = memo<HeteroDeviceSwitcherProps>(({ agentId }) => {
disabled={!d.online}
icon={getDeviceIcon(d.platform)}
key={d.deviceId}
label={d.hostname}
label={d.friendlyName || d.hostname || d.deviceId}
desc={
<>
<span className={d.online ? styles.dotOnline : styles.dotOffline} />
+96 -3
View File
@@ -1,7 +1,9 @@
import { REMOTE_HETEROGENEOUS_AGENT_CONFIGS } from '@lobechat/heterogeneous-agents';
import { z } from 'zod';
import { DeviceModel } from '@/database/models/device';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { deviceProxy } from '@/server/services/toolExecution/deviceProxy';
// Derive the zod enum from the canonical config so new platforms are
@@ -16,11 +18,11 @@ const remotePlatformEnum = z.enum(
const CAPABILITY_TIMEOUT_MS = 5_000;
const PROFILE_TIMEOUT_MS = 5_000;
const deviceProcedure = authedProcedure.use(async (opts) => {
const deviceProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
return opts.next({
ctx: { userId: ctx.userId },
ctx: { deviceModel: new DeviceModel(ctx.serverDB, ctx.userId), userId: ctx.userId },
});
});
@@ -105,11 +107,102 @@ export const deviceRouter = router({
return deviceProxy.queryDeviceSystemInfo(ctx.userId, input.deviceId);
}),
/**
* All devices the user has ever registered (incl. offline), each enriched
* with a live `online` flag from the gateway's in-memory WS sessions.
*
* A union, not just the DB rows: a device may be connected but not yet in
* the DB (old client that predates auto-register, or registration still in
* flight). Those are surfaced as transient entries so the picker never loses
* a currently-reachable device during rollout.
*/
listDevices: deviceProcedure.query(async ({ ctx }) => {
return deviceProxy.queryDeviceList(ctx.userId);
const [registered, onlineList] = await Promise.all([
ctx.deviceModel.query(),
deviceProxy.queryDeviceList(ctx.userId),
]);
const onlineMap = new Map(onlineList.map((d) => [d.deviceId, d]));
const seen = new Set<string>();
const fromDb = registered.map((d) => {
seen.add(d.deviceId);
const live = onlineMap.get(d.deviceId);
return {
defaultCwd: d.defaultCwd,
deviceId: d.deviceId,
friendlyName: d.friendlyName,
hostname: d.hostname ?? live?.hostname ?? null,
identitySource: d.identitySource,
lastSeen: d.lastSeenAt.toISOString(),
online: onlineMap.has(d.deviceId),
platform: d.platform ?? live?.platform ?? null,
recentCwds: d.recentCwds,
registered: true,
};
});
// Online but not yet persisted — transient until the client auto-registers.
const ghosts = onlineList
.filter((d) => !seen.has(d.deviceId))
.map((d) => ({
defaultCwd: null,
deviceId: d.deviceId,
friendlyName: null,
hostname: d.hostname ?? null,
identitySource: null,
lastSeen: d.lastSeen,
online: true,
platform: d.platform ?? null,
recentCwds: [] as string[],
registered: false,
}));
return [...fromDb, ...ghosts];
}),
/**
* Auto-register the calling device (desktop after OIDC login / CLI on first
* `lh connect`). Upserts on (userId, deviceId); user-owned fields are
* preserved on conflict.
*/
register: deviceProcedure
.input(
z.object({
deviceId: z.string().min(1).max(64),
hostname: z.string().nullable().optional(),
identitySource: z.enum(['machine-id', 'fallback']),
platform: z.string().max(20).nullable().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
return ctx.deviceModel.register(input);
}),
removeDevice: deviceProcedure
.input(z.object({ deviceId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.deviceModel.delete(input.deviceId);
return { success: true };
}),
status: deviceProcedure.query(async ({ ctx }) => {
return deviceProxy.queryDeviceStatus(ctx.userId);
}),
/** User-editable fields only — never the machine-reported identity columns. */
updateDevice: deviceProcedure
.input(
z.object({
defaultCwd: z.string().nullable().optional(),
deviceId: z.string(),
friendlyName: z.string().max(100).nullable().optional(),
recentCwds: z.array(z.string()).max(20).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { deviceId, ...value } = input;
await ctx.deviceModel.update(deviceId, value);
return { success: true };
}),
});