feat(device): record CPU architecture on the devices table (#19510)

This commit is contained in:
Arvin Xu
2026-09-19 22:36:36 +08:00
committed by GitHub
parent 066d22fd0c
commit 0cc24ebcf0
15 changed files with 37377 additions and 2 deletions
+13
View File
@@ -4,6 +4,7 @@ import type { DeviceIdentity } from '@lobechat/device-identity';
import { deriveDeviceId, deriveScopedFallbackId } from '@lobechat/device-identity';
import { createLambdaClient } from '../api/client';
import { cliVersion } from '../pkg';
import { isTransientNetworkError } from '../utils/error';
const WORKSPACE_TOKEN_RETRY_DELAYS_MS = [250, 1000, 2500];
@@ -47,9 +48,15 @@ export async function registerDevice(
): Promise<void> {
const trpc = createLambdaClient(auth);
await trpc.device.register.mutate({
architecture: os.arch(),
deviceId: identity.deviceId,
hostname: os.hostname(),
identitySource: identity.identitySource,
metadata: {
cliVersion,
node: process.versions.node,
osRelease: os.release(),
},
platform: process.platform,
});
}
@@ -105,9 +112,15 @@ export async function registerWorkspaceDevice(
): Promise<void> {
const trpc = createLambdaClient(auth, workspaceId);
await trpc.device.registerWorkspaceDevice.mutate({
architecture: os.arch(),
deviceId: identity.deviceId,
hostname: os.hostname(),
identitySource: identity.identitySource,
metadata: {
cliVersion,
node: process.versions.node,
osRelease: os.release(),
},
platform: process.platform,
visibility,
});
@@ -15,6 +15,7 @@ import {
import { type ILocalSystemService, LocalSystemExecutionRuntime } from '@lobechat/tool-runtime';
import AuvService, { type AuvRunCommandParams } from '@/services/auvSrv';
import { backfillDeviceArchitecture } from '@/services/deviceArchitectureBackfill';
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import ImessageBridgeService from '@/services/imessageBridgeSrv';
import { findHeteroExecProcesses } from '@/utils/heteroExecProcess';
@@ -256,7 +257,26 @@ export default class GatewayConnectionCtr extends ControllerModule {
hostname: string;
platform: string;
}> {
return this.service.getDeviceInfo();
const info = this.service.getDeviceInfo();
try {
const [serverUrl, token] = await Promise.all([
this.remoteServerConfigCtr.getRemoteServerUrl(),
this.remoteServerConfigCtr.getAccessToken(),
]);
if (serverUrl && token && info.deviceId !== 'unknown') {
const headers = { 'Content-Type': 'application/json', 'Oidc-Auth': token };
setDesktopUserAgentHeader(headers);
await backfillDeviceArchitecture({
architecture: os.arch(),
deviceId: info.deviceId,
headers,
serverUrl,
});
}
} catch (error) {
logger.warn('Could not backfill local device architecture; will retry on next read', error);
}
return info;
}
/**
@@ -217,7 +217,12 @@ vi.mock('@lobechat/heterogeneous-agents/scanHost', () => ({
}));
vi.mock('node:os', () => ({
default: { hostname: vi.fn(() => 'mock-hostname'), tmpdir: vi.fn(() => '/tmp') },
default: {
arch: vi.fn(() => 'arm64'),
hostname: vi.fn(() => 'mock-hostname'),
release: vi.fn(() => '24.0.0'),
tmpdir: vi.fn(() => '/tmp'),
},
}));
vi.mock('@lobechat/device-gateway-client', () => ({
@@ -325,6 +330,10 @@ describe('GatewayConnectionCtr', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(Response.json({ result: { data: { json: [] } } })),
);
vi.useFakeTimers();
resolveRemotePlatformRuntimeMock.mockImplementation(
async (type: 'hermes' | 'openclaw', baseEnv: NodeJS.ProcessEnv = process.env) => ({
@@ -355,6 +364,7 @@ describe('GatewayConnectionCtr', () => {
afterEach(() => {
ctr.disconnect();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.useRealTimers();
});
@@ -2196,6 +2206,49 @@ describe('GatewayConnectionCtr', () => {
});
describe('getDeviceInfo', () => {
it('backfills the registered local device when reading its info without reconnecting', async () => {
mockStoreGet.mockImplementation((key: string) =>
key === 'gatewayDeviceId' ? 'my-device' : false,
);
mockGatewayConnectionSrv.loadOrCreateDeviceId();
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce(
Response.json({
result: {
data: {
json: [
{
architecture: null,
deviceId: 'my-device',
identitySource: 'machine-id',
registered: true,
scope: 'personal',
},
],
},
},
}),
);
const info = await ctr.getDeviceInfo();
expect(info.deviceId).toBe('my-device');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(JSON.parse(fetchMock.mock.calls[1][1]!.body as string)).toEqual({
json: {
architecture: 'arm64',
deviceId: 'my-device',
},
});
expect(MockGatewayClient.lastInstance).toBeNull();
});
it('still returns local info if the registry cannot be reached', async () => {
mockGatewayConnectionSrv.loadOrCreateDeviceId();
vi.mocked(fetch).mockRejectedValueOnce(new Error('offline'));
await expect(ctr.getDeviceInfo()).resolves.toMatchObject({ hostname: 'mock-hostname' });
});
it('should return device information', async () => {
mockStoreGet.mockImplementation((key: string) => {
if (key === 'gatewayEnabled') return true;
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { backfillDeviceArchitecture } from '../deviceArchitectureBackfill';
const options = {
architecture: 'arm64',
deviceId: 'local-device',
headers: { 'Oidc-Auth': 'test-token' },
serverUrl: 'https://server.example.com',
};
const localDevice = {
architecture: null,
deviceId: options.deviceId,
identitySource: 'machine-id',
registered: true,
scope: 'personal',
};
afterEach(() => vi.unstubAllGlobals());
describe('backfillDeviceArchitecture', () => {
it('repairs a missing local architecture without overwriting metadata or settings', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(Response.json({ result: { data: { json: [localDevice] } } }))
.mockResolvedValueOnce(Response.json({ result: { data: { json: {} } } }));
vi.stubGlobal('fetch', fetchMock);
await backfillDeviceArchitecture(options);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][0]).toBe(
`${options.serverUrl}/trpc/lambda/device.updateDeviceInfo`,
);
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({
json: { architecture: 'arm64', deviceId: options.deviceId },
});
});
it.each([
{ devices: [{ ...localDevice, architecture: 'x64' }] },
{ devices: [{ ...localDevice, deviceId: 'remote-device' }] },
{ devices: [{ ...localDevice, scope: 'workspace' }] },
{ devices: [{ ...localDevice, registered: false }] },
{ devices: [] },
])('does not register devices that do not need local backfill: %j', async ({ devices }) => {
const fetchMock = vi
.fn()
.mockResolvedValue(Response.json({ result: { data: { json: devices } } }));
vi.stubGlobal('fetch', fetchMock);
await backfillDeviceArchitecture(options);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('does not write after a failed read', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 503 }));
vi.stubGlobal('fetch', fetchMock);
await expect(backfillDeviceArchitecture(options)).rejects.toThrow('HTTP 503');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('retries on the next read after a failed write', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(Response.json({ result: { data: { json: [localDevice] } } }))
.mockResolvedValueOnce(new Response(null, { status: 503 }))
.mockResolvedValueOnce(Response.json({ result: { data: { json: [localDevice] } } }))
.mockResolvedValueOnce(Response.json({ result: { data: { json: {} } } }));
vi.stubGlobal('fetch', fetchMock);
await expect(backfillDeviceArchitecture(options)).rejects.toThrow('HTTP 503');
await expect(backfillDeviceArchitecture(options)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(4);
});
});
@@ -0,0 +1,38 @@
interface DeviceArchitectureBackfillOptions {
architecture: string;
deviceId: string;
headers: Record<string, string>;
serverUrl: string;
}
export const backfillDeviceArchitecture = async ({
architecture,
deviceId,
headers,
serverUrl,
}: DeviceArchitectureBackfillOptions): Promise<void> => {
const response = await fetch(`${serverUrl}/trpc/lambda/device.listDevices`, {
headers,
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error(`Device registry read failed: HTTP ${response.status}`);
const payload = await response.json();
const devices = payload?.result?.data?.json;
if (!Array.isArray(devices)) return;
const device = devices.find(
(item) => item.deviceId === deviceId && item.scope === 'personal' && item.registered,
);
if (!device || device.architecture) return;
const updated = await fetch(`${serverUrl}/trpc/lambda/device.updateDeviceInfo`, {
body: JSON.stringify({
json: { architecture, deviceId },
}),
headers,
method: 'POST',
signal: AbortSignal.timeout(5000),
});
if (!updated.ok) throw new Error(`Device architecture backfill failed: HTTP ${updated.status}`);
};
@@ -104,9 +104,11 @@ interface RpcHandler {
interface DeviceRegistrar {
(info: {
architecture: string;
deviceId: string;
hostname: string;
identitySource: IdentitySource;
metadata: Record<string, string>;
platform: string;
}): Promise<void>;
}
@@ -383,9 +385,16 @@ export default class GatewayConnectionService extends ServiceModule {
if (userId) {
const identity = await this.resolveDeviceIdentity(userId);
await this.deviceRegistrar?.({
architecture: os.arch(),
deviceId: identity.deviceId,
hostname: os.hostname(),
identitySource: identity.identitySource,
metadata: {
appVersion: app.getVersion(),
electron: process.versions.electron,
node: process.versions.node,
osRelease: os.release(),
},
platform: process.platform,
}).catch((err) => {
logger.warn(`Device registration failed (non-fatal): ${(err as Error).message}`);
+32
View File
@@ -1116,6 +1116,7 @@ export const deviceRouter = router({
const channels = channelsByDevice.get(d.deviceId) ?? [];
const live = channels[0];
return {
architecture: d.architecture,
channels,
defaultCwd: d.defaultCwd,
deviceId: d.deviceId,
@@ -1226,9 +1227,15 @@ export const deviceRouter = router({
.use(serverDatabase)
.input(
z.object({
architecture: z.string().max(20).nullish(),
deviceId: z.string().min(1).max(64),
hostname: z.string().nullish(),
identitySource: z.enum(['machine-id', 'fallback']),
/** Extensible client-reported info bag; free-form, size-capped to guard the column. */
metadata: z
.record(z.string().max(64), z.string().max(200))
.refine((m) => Object.keys(m).length <= 20, 'metadata supports at most 20 keys')
.nullish(),
platform: z.string().max(20).nullish(),
// 'private' enrolls the device for the calling member only (settings
// page "Private" tab / `lh connect --workspace <id> --private`);
@@ -1550,9 +1557,15 @@ export const deviceRouter = router({
register: deviceProcedure
.input(
z.object({
architecture: z.string().max(20).nullish(),
deviceId: z.string().min(1).max(64),
hostname: z.string().nullish(),
identitySource: z.enum(['machine-id', 'fallback']),
/** Extensible client-reported info bag; free-form, size-capped to guard the column. */
metadata: z
.record(z.string().max(64), z.string().max(200))
.refine((m) => Object.keys(m).length <= 20, 'metadata supports at most 20 keys')
.nullish(),
platform: z.string().max(20).nullish(),
}),
)
@@ -1597,4 +1610,23 @@ export const deviceRouter = router({
await ctx.deviceModel.update(deviceId, { ...value, workingDirs: nextWorkingDirs });
return { success: true };
}),
updateDeviceInfo: deviceProcedure
.input(
z.object({
architecture: z.string().min(1).max(20).optional(),
deviceId: z.string().min(1).max(64),
hostname: z.string().optional(),
metadata: z
.record(z.string().max(64), z.string().max(200))
.refine((m) => Object.keys(m).length <= 20, 'metadata supports at most 20 keys')
.optional(),
platform: z.string().max(20).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { deviceId, ...value } = input;
const device = await ctx.deviceModel.updateDeviceInfo(deviceId, value);
if (!device) throw new TRPCError({ code: 'NOT_FOUND', message: 'Device not found' });
return { success: true };
}),
});
+2
View File
@@ -1232,6 +1232,8 @@ table devices {
identity_source varchar(20) [not null]
hostname text
platform varchar(20)
architecture varchar(20)
metadata jsonb
friendly_name text
default_cwd text
recent_cwds text[] [not null, default: `[]`]
@@ -0,0 +1,3 @@
ALTER TABLE "devices" ADD COLUMN IF NOT EXISTS "architecture" varchar(20);--> statement-breakpoint
COMMENT ON COLUMN "devices"."architecture" IS 'CPU architecture reported by the client (process.arch: x64 | arm64). NULL for devices that have not reported since this column landed; only a fresh client report fills it (no backfill).';--> statement-breakpoint
ALTER TABLE "devices" ADD COLUMN IF NOT EXISTS "metadata" jsonb;--> statement-breakpoint
File diff suppressed because it is too large Load Diff
@@ -1162,6 +1162,13 @@
"when": 1789610500888,
"tag": "0165_expertise_rejection_provenance",
"breakpoints": true
},
{
"idx": 166,
"version": "7",
"when": 1789816134383,
"tag": "0166_device_architecture",
"breakpoints": true
}
],
"version": "6"
@@ -25,27 +25,97 @@ afterEach(async () => {
});
describe('DeviceModel', () => {
describe('updateDeviceInfo', () => {
it('does not create missing devices', async () => {
expect(
await deviceModel.updateDeviceInfo('missing', { architecture: 'arm64' }),
).toBeUndefined();
expect(await deviceModel.findByDeviceId('missing')).toBeUndefined();
});
it('does not update another users device', async () => {
const other = new DeviceModel(serverDB, otherUserId);
await other.register({ deviceId: 'other-device', identitySource: 'machine-id' });
expect(
await deviceModel.updateDeviceInfo('other-device', { architecture: 'arm64' }),
).toBeUndefined();
expect((await other.findByDeviceId('other-device'))?.architecture).toBeNull();
});
it('updates reported metadata without changing user settings or identity', async () => {
await deviceModel.register({ deviceId: 'info-device', identitySource: 'machine-id' });
await deviceModel.update('info-device', {
friendlyName: 'My device',
defaultCwd: '/projects',
});
const updated = await deviceModel.updateDeviceInfo('info-device', {
architecture: 'arm64',
metadata: { appVersion: '2.0.0' },
});
expect(updated).toMatchObject({
architecture: 'arm64',
metadata: { appVersion: '2.0.0' },
identitySource: 'machine-id',
friendlyName: 'My device',
defaultCwd: '/projects',
});
});
});
describe('register', () => {
it('should insert a new device', async () => {
const result = await deviceModel.register({
architecture: 'arm64',
deviceId: 'dev-1',
hostname: 'My-Mac.local',
identitySource: 'machine-id',
metadata: { cliVersion: '1.2.3' },
platform: 'darwin',
});
expect(result.id).toBeDefined();
expect(result).toMatchObject({
architecture: 'arm64',
deviceId: 'dev-1',
hostname: 'My-Mac.local',
identitySource: 'machine-id',
metadata: { cliVersion: '1.2.3' },
platform: 'darwin',
userId,
});
});
it('should default architecture to NULL for older clients that omit it', async () => {
const result = await deviceModel.register({
deviceId: 'dev-noarch',
identitySource: 'machine-id',
});
expect(result.architecture).toBeNull();
expect(result.metadata).toBeNull();
});
it('backfills architecture without replacing existing machine metadata', async () => {
await deviceModel.register({
deviceId: 'dev-backfill',
hostname: 'My-Mac',
identitySource: 'machine-id',
metadata: { appVersion: '2.0.0' },
platform: 'darwin',
});
const device = await deviceModel.updateDeviceInfo('dev-backfill', { architecture: 'arm64' });
expect(device).toMatchObject({
architecture: 'arm64',
hostname: 'My-Mac',
metadata: { appVersion: '2.0.0' },
platform: 'darwin',
});
});
it('should upsert on (userId, deviceId) and refresh machine fields', async () => {
await deviceModel.register({
architecture: 'x64',
deviceId: 'dev-1',
hostname: 'old-host',
identitySource: 'fallback',
@@ -53,9 +123,11 @@ describe('DeviceModel', () => {
});
await deviceModel.register({
architecture: 'arm64',
deviceId: 'dev-1',
hostname: 'new-host',
identitySource: 'machine-id',
metadata: { appVersion: '2.0.0' },
platform: 'darwin',
});
@@ -64,8 +136,10 @@ describe('DeviceModel', () => {
});
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
architecture: 'arm64',
hostname: 'new-host',
identitySource: 'machine-id',
metadata: { appVersion: '2.0.0' },
platform: 'darwin',
});
});
+30
View File
@@ -26,9 +26,13 @@ export class WorkspaceDevicePrivateConflictError extends Error {
}
export interface RegisterDeviceParams {
/** CPU architecture reported by the client (`process.arch`); optional for older clients. */
architecture?: string | null;
deviceId: string;
hostname?: string | null;
identitySource: string;
/** Extensible client-reported info bag (app version, runtime versions, OS release). */
metadata?: Record<string, string> | null;
platform?: string | null;
}
@@ -102,18 +106,22 @@ export class DeviceModel {
const [result] = await this.db
.insert(devices)
.values({
architecture: params.architecture,
deviceId: params.deviceId,
hostname: params.hostname,
identitySource: params.identitySource,
metadata: params.metadata,
lastSeenAt: now,
platform: params.platform,
userId: this.userId,
})
.onConflictDoUpdate({
set: {
architecture: params.architecture,
hostname: params.hostname,
identitySource: params.identitySource,
lastSeenAt: now,
metadata: params.metadata,
platform: params.platform,
},
target: [devices.userId, devices.deviceId],
@@ -167,9 +175,11 @@ export class DeviceModel {
const [result] = await this.db
.insert(devices)
.values({
architecture: params.architecture,
deviceId: params.deviceId,
hostname: params.hostname,
identitySource: params.identitySource,
metadata: params.metadata,
lastSeenAt: now,
platform: params.platform,
// Set for enrollments driven from the owner's personal device list —
@@ -203,9 +213,11 @@ export class DeviceModel {
// `targetWhere`.
.onConflictDoUpdate({
set: {
architecture: params.architecture,
hostname: params.hostname,
identitySource: params.identitySource,
lastSeenAt: now,
metadata: params.metadata,
platform: params.platform,
visibility: params.visibility === 'public' ? 'public' : sql`${devices.visibility}`,
},
@@ -299,6 +311,24 @@ export class DeviceModel {
.where(and(eq(devices.userId, this.userId), eq(devices.deviceId, deviceId)));
};
updateDeviceInfo = async (
deviceId: string,
value: Pick<RegisterDeviceParams, 'architecture' | 'hostname' | 'metadata' | 'platform'>,
) => {
const [device] = await this.db
.update(devices)
.set({ ...value, updatedAt: new Date() })
.where(
and(
eq(devices.userId, this.userId),
eq(devices.deviceId, deviceId),
isNull(devices.workspaceId),
),
)
.returning();
return device;
};
delete = async (deviceId: string) => {
return this.db
.delete(devices)
+8
View File
@@ -65,6 +65,14 @@ export const devices = pgTable(
hostname: text('hostname'),
/** 'darwin' | 'win32' | 'linux' */
platform: varchar('platform', { length: 20 }),
/**
* CPU architecture as reported by the client (`process.arch`: 'x64' | 'arm64',
* stored raw no normalization so future values like linux arm keep working).
* Only a device running a client new enough to report it gets a value;
* historical rows stay NULL (arch is not derivable from stored data).
*/
architecture: varchar('architecture', { length: 20 }),
metadata: jsonb('metadata').$type<Record<string, string>>(),
/** User-editable alias */
friendlyName: text('friendly_name'),
+1
View File
@@ -353,6 +353,7 @@ export interface DeviceEnroller {
}
export interface DeviceListItem {
architecture?: string | null;
channels: DeviceChannel[];
defaultCwd: string | null;
deviceId: string;