mirror of
https://github.com/lobehub/lobehub.git
synced 2026-09-20 04:56:13 +08:00
✨ feat(device): record CPU architecture on the devices table (#19510)
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 user’s 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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -353,6 +353,7 @@ export interface DeviceEnroller {
|
||||
}
|
||||
|
||||
export interface DeviceListItem {
|
||||
architecture?: string | null;
|
||||
channels: DeviceChannel[];
|
||||
defaultCwd: string | null;
|
||||
deviceId: string;
|
||||
|
||||
Reference in New Issue
Block a user