diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 5ec9db4..f818e24 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -1742,6 +1742,36 @@ function normalizeDeviceCapabilities( }; } +function refreshDeviceCapabilityLastSeenAt( + raw: DeviceCapabilitiesInput | undefined, + fallbackLastSeenAt: string, + preserveExistingLastSeenAt = true, +): DeviceCapabilitiesInput | undefined { + if (!raw) { + return raw; + } + return { + gui: raw.gui + ? { + ...raw.gui, + lastSeenAt: + preserveExistingLastSeenAt + ? trimToDefined(raw.gui.lastSeenAt) ?? fallbackLastSeenAt + : fallbackLastSeenAt, + } + : raw.gui, + cli: raw.cli + ? { + ...raw.cli, + lastSeenAt: + preserveExistingLastSeenAt + ? trimToDefined(raw.cli.lastSeenAt) ?? fallbackLastSeenAt + : fallbackLastSeenAt, + } + : raw.cli, + }; +} + function normalizePreferredExecutionMode(value: unknown): DeviceExecutionMode { return value === "gui" ? "gui" : "cli"; } @@ -7273,6 +7303,7 @@ export async function updateDevice(deviceId: string, payload: DeviceUpdatePayloa const device = await mutateState((state) => { const nextDevice = state.devices.find((item) => item.id === deviceId); if (!nextDevice) throw new Error("DEVICE_NOT_FOUND"); + const nextLastSeenAt = nowIso(); if (payload.name) nextDevice.name = payload.name.trim(); if (payload.avatar) nextDevice.avatar = payload.avatar.trim().slice(0, 2) || nextDevice.avatar; @@ -7283,21 +7314,23 @@ export async function updateDevice(deviceId: string, payload: DeviceUpdatePayloa if (payload.projects) { nextDevice.projects = payload.projects.filter(Boolean); } - if (payload.capabilities) { - nextDevice.capabilities = normalizeDeviceCapabilities( - { - gui: payload.capabilities.gui ?? nextDevice.capabilities?.gui, - cli: payload.capabilities.cli ?? nextDevice.capabilities?.cli, - }, - trimToDefined(payload.capabilities.gui?.lastSeenAt) ?? - trimToDefined(payload.capabilities.cli?.lastSeenAt) ?? - nextDevice.lastSeenAt, - ); - } + nextDevice.capabilities = normalizeDeviceCapabilities( + refreshDeviceCapabilityLastSeenAt( + payload.capabilities + ? { + gui: payload.capabilities.gui ?? nextDevice.capabilities?.gui, + cli: payload.capabilities.cli ?? nextDevice.capabilities?.cli, + } + : nextDevice.capabilities, + nextLastSeenAt, + Boolean(payload.capabilities), + ), + nextLastSeenAt, + ); if (payload.preferredExecutionMode !== undefined) { nextDevice.preferredExecutionMode = normalizePreferredExecutionMode(payload.preferredExecutionMode); } - nextDevice.lastSeenAt = nowIso(); + nextDevice.lastSeenAt = nextLastSeenAt; return nextDevice; }); publishBossEvent("devices.updated", { deviceId }); @@ -7635,6 +7668,7 @@ export async function upsertDeviceHeartbeat(payload: { if (device.token && payload.token && device.token !== payload.token && !claimedEnrollment) { throw new Error("DEVICE_TOKEN_MISMATCH"); } + const nextLastSeenAt = nowIso(); device.name = payload.name; device.avatar = payload.avatar; device.account = payload.account; @@ -7643,12 +7677,16 @@ export async function upsertDeviceHeartbeat(payload: { device.projects = payload.projects; device.quota5h = payload.quota5h; device.quota7d = payload.quota7d; - device.lastSeenAt = nowIso(); + device.lastSeenAt = nextLastSeenAt; device.endpoint = payload.endpoint ?? device.endpoint; device.token = claimedEnrollment?.token ?? payload.token ?? device.token; device.capabilities = normalizeDeviceCapabilities( - payload.capabilities ?? device.capabilities, - device.lastSeenAt, + refreshDeviceCapabilityLastSeenAt( + payload.capabilities ?? device.capabilities, + nextLastSeenAt, + Boolean(payload.capabilities), + ), + nextLastSeenAt, ); if (device.preferredExecutionMode === undefined && payload.preferredExecutionMode !== undefined) { device.preferredExecutionMode = normalizePreferredExecutionMode(payload.preferredExecutionMode); diff --git a/tests/device-gui-cli-capabilities.test.ts b/tests/device-gui-cli-capabilities.test.ts index 04df0e7..0ea8347 100644 --- a/tests/device-gui-cli-capabilities.test.ts +++ b/tests/device-gui-cli-capabilities.test.ts @@ -240,3 +240,56 @@ test("device heartbeat does not overwrite the preferred execution mode chosen in assert.ok(device); assert.equal(device.preferredExecutionMode, "gui"); }); + +test("device heartbeat without capability payload refreshes stale gui cli lastSeenAt", async () => { + await setup(); + + const staleCapabilityTime = "2026-03-25T11:52:00+08:00"; + const staleState = await readState(); + await writeState({ + ...staleState, + devices: staleState.devices.map((device) => + device.id === "mac-studio" + ? { + ...device, + lastSeenAt: staleCapabilityTime, + capabilities: { + gui: { + ...device.capabilities?.gui, + connected: true, + lastSeenAt: staleCapabilityTime, + lastActiveProjectId: "master-agent", + }, + cli: { + ...device.capabilities?.cli, + connected: true, + lastSeenAt: staleCapabilityTime, + lastActiveProjectId: "master-agent", + }, + }, + } + : device, + ), + }); + + await upsertDeviceHeartbeat({ + deviceId: "mac-studio", + name: "Mac Studio", + avatar: "M", + account: "17600003315", + status: "online", + quota5h: 72, + quota7d: 86, + preferredExecutionMode: "cli", + projects: ["硬件审计协作"], + endpoint: "mac://kris.local", + }); + + const state = await readState(); + const device = state.devices.find((item) => item.id === "mac-studio"); + + assert.ok(device); + assert.notEqual(device.lastSeenAt, staleCapabilityTime); + assert.equal(device.capabilities?.gui.lastSeenAt, device.lastSeenAt); + assert.equal(device.capabilities?.cli.lastSeenAt, device.lastSeenAt); +});