diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 3eec15b0296..d7c43b22749 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1934,6 +1934,10 @@ public struct EnvironmentSummary: Codable, Sendable { public let status: EnvironmentStatus public let platform: String? public let sessionhost: Bool? + public let lastconnectedatms: Int? + public let lastdisconnectedatms: Int? + public let lastseenatms: Int? + public let lastseenreason: String? public let trust: String? public let capabilities: [String]? public let desktop: Bool? @@ -1946,6 +1950,10 @@ public struct EnvironmentSummary: Codable, Sendable { status: EnvironmentStatus, platform: String? = nil, sessionhost: Bool? = nil, + lastconnectedatms: Int? = nil, + lastdisconnectedatms: Int? = nil, + lastseenatms: Int? = nil, + lastseenreason: String? = nil, trust: String? = nil, capabilities: [String]? = nil, desktop: Bool? = nil, @@ -1957,6 +1965,10 @@ public struct EnvironmentSummary: Codable, Sendable { self.status = status self.platform = platform self.sessionhost = sessionhost + self.lastconnectedatms = lastconnectedatms + self.lastdisconnectedatms = lastdisconnectedatms + self.lastseenatms = lastseenatms + self.lastseenreason = lastseenreason self.trust = trust self.capabilities = capabilities self.desktop = desktop @@ -1970,6 +1982,10 @@ public struct EnvironmentSummary: Codable, Sendable { case status case platform case sessionhost = "sessionHost" + case lastconnectedatms = "lastConnectedAtMs" + case lastdisconnectedatms = "lastDisconnectedAtMs" + case lastseenatms = "lastSeenAtMs" + case lastseenreason = "lastSeenReason" case trust case capabilities case desktop @@ -2002,6 +2018,10 @@ public struct EnvironmentsCreateResult: Codable, Sendable { public let status: EnvironmentStatus public let platform: String? public let sessionhost: Bool? + public let lastconnectedatms: Int? + public let lastdisconnectedatms: Int? + public let lastseenatms: Int? + public let lastseenreason: String? public let trust: String? public let capabilities: [String]? public let desktop: Bool? @@ -2014,6 +2034,10 @@ public struct EnvironmentsCreateResult: Codable, Sendable { status: EnvironmentStatus, platform: String? = nil, sessionhost: Bool? = nil, + lastconnectedatms: Int? = nil, + lastdisconnectedatms: Int? = nil, + lastseenatms: Int? = nil, + lastseenreason: String? = nil, trust: String? = nil, capabilities: [String]? = nil, desktop: Bool? = nil, @@ -2025,6 +2049,10 @@ public struct EnvironmentsCreateResult: Codable, Sendable { self.status = status self.platform = platform self.sessionhost = sessionhost + self.lastconnectedatms = lastconnectedatms + self.lastdisconnectedatms = lastdisconnectedatms + self.lastseenatms = lastseenatms + self.lastseenreason = lastseenreason self.trust = trust self.capabilities = capabilities self.desktop = desktop @@ -2038,6 +2066,10 @@ public struct EnvironmentsCreateResult: Codable, Sendable { case status case platform case sessionhost = "sessionHost" + case lastconnectedatms = "lastConnectedAtMs" + case lastdisconnectedatms = "lastDisconnectedAtMs" + case lastseenatms = "lastSeenAtMs" + case lastseenreason = "lastSeenReason" case trust case capabilities case desktop @@ -2070,6 +2102,10 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { public let status: EnvironmentStatus public let platform: String? public let sessionhost: Bool? + public let lastconnectedatms: Int? + public let lastdisconnectedatms: Int? + public let lastseenatms: Int? + public let lastseenreason: String? public let trust: String? public let capabilities: [String]? public let desktop: Bool? @@ -2082,6 +2118,10 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { status: EnvironmentStatus, platform: String? = nil, sessionhost: Bool? = nil, + lastconnectedatms: Int? = nil, + lastdisconnectedatms: Int? = nil, + lastseenatms: Int? = nil, + lastseenreason: String? = nil, trust: String? = nil, capabilities: [String]? = nil, desktop: Bool? = nil, @@ -2093,6 +2133,10 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { self.status = status self.platform = platform self.sessionhost = sessionhost + self.lastconnectedatms = lastconnectedatms + self.lastdisconnectedatms = lastdisconnectedatms + self.lastseenatms = lastseenatms + self.lastseenreason = lastseenreason self.trust = trust self.capabilities = capabilities self.desktop = desktop @@ -2106,6 +2150,10 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { case status case platform case sessionhost = "sessionHost" + case lastconnectedatms = "lastConnectedAtMs" + case lastdisconnectedatms = "lastDisconnectedAtMs" + case lastseenatms = "lastSeenAtMs" + case lastseenreason = "lastSeenReason" case trust case capabilities case desktop @@ -2154,6 +2202,10 @@ public struct EnvironmentsStatusResult: Codable, Sendable { public let status: EnvironmentStatus public let platform: String? public let sessionhost: Bool? + public let lastconnectedatms: Int? + public let lastdisconnectedatms: Int? + public let lastseenatms: Int? + public let lastseenreason: String? public let trust: String? public let capabilities: [String]? public let desktop: Bool? @@ -2166,6 +2218,10 @@ public struct EnvironmentsStatusResult: Codable, Sendable { status: EnvironmentStatus, platform: String? = nil, sessionhost: Bool? = nil, + lastconnectedatms: Int? = nil, + lastdisconnectedatms: Int? = nil, + lastseenatms: Int? = nil, + lastseenreason: String? = nil, trust: String? = nil, capabilities: [String]? = nil, desktop: Bool? = nil, @@ -2177,6 +2233,10 @@ public struct EnvironmentsStatusResult: Codable, Sendable { self.status = status self.platform = platform self.sessionhost = sessionhost + self.lastconnectedatms = lastconnectedatms + self.lastdisconnectedatms = lastdisconnectedatms + self.lastseenatms = lastseenatms + self.lastseenreason = lastseenreason self.trust = trust self.capabilities = capabilities self.desktop = desktop @@ -2190,6 +2250,10 @@ public struct EnvironmentsStatusResult: Codable, Sendable { case status case platform case sessionhost = "sessionHost" + case lastconnectedatms = "lastConnectedAtMs" + case lastdisconnectedatms = "lastDisconnectedAtMs" + case lastseenatms = "lastSeenAtMs" + case lastseenreason = "lastSeenReason" case trust case capabilities case desktop diff --git a/docs/plan/runners.md b/docs/plan/runners.md index 69734099590..e0d9a244acf 100644 --- a/docs/plan/runners.md +++ b/docs/plan/runners.md @@ -313,22 +313,28 @@ Revision 1's design rule stands: normal state is silent; only exceptions speak. Additions: - **Use the existing environment type discriminant** for picker grouping: - local gateway, connected execution-capable nodes, worker environments, and - the separate cloud profiles list. `sessionHost` is deferred to milestone 6, - where device runners introduce the capability fact that needs it. + local gateway, execution-capable nodes, worker environments, and the + separate cloud profiles list. Device-runner inventory adds `sessionHost` + without creating another place ontology. - **Where picker regrouped** (`ui/src/pages/new-session/place-picker.ts`): sections "This gateway" / "Devices" / "Cloud". Device rows intersect the - environment catalog with connected, execution-capable nodes; cloud + environment catalog with execution-capable paired nodes; connected rows are + selectable, while remembered offline rows stay visible but disabled. Cloud profiles remain their separate list. Folder and destination stay orthogonal. +- **Node connection history is server-owned.** Successful node hello records + `lastConnectedAtMs`; retiring that exact pairing generation and connection + records `lastDisconnectedAtMs` in the existing node surface. `node.list` and + `environments.list/status` project those facts. The picker uses the existing + topology refresh events and distinguishes "Never connected", "Offline for + …", and the legacy/unclean-exit fallback "Last seen …". Connected rows stay + silent. This adds no config, event, or SQLite schema-version surface. - **Placement chip** on the session header: shows quiet current placement; active cloud placements reclaim through `sessions.reclaim` with "Bring home". Stop-and-continue moves arrive with milestone 8. -- **Remaining milestone work**: live presence and pairing subscriptions, the - admin-gated "Connect a machine…" foot, busy and never-connected states, - and additive `EnvironmentSummary` platform, session-host, trust, and runner - version facts. `runner-offline` then shows a banner with the recorded reason - and its recovery verbs. +- **Remaining milestone work**: the admin-gated "Connect a machine…" foot and + busy/slot state. `runner-offline` then shows a banner with the recorded + reason and its recovery verbs. ### Cloud convergence (milestone 10) diff --git a/packages/gateway-protocol/src/schema/environments.test.ts b/packages/gateway-protocol/src/schema/environments.test.ts index 23abddbc29f..6c6e2b91ec2 100644 --- a/packages/gateway-protocol/src/schema/environments.test.ts +++ b/packages/gateway-protocol/src/schema/environments.test.ts @@ -114,6 +114,23 @@ describe("worker environment protocol schemas", () => { ).toBe(true); }); + it("accepts bounded node lifecycle history and rejects malformed timestamps", () => { + const node = { + id: "node:build-mac", + type: "node", + status: "unavailable", + lastConnectedAtMs: 1_000, + lastDisconnectedAtMs: 2_000, + lastSeenAtMs: 1_500, + lastSeenReason: "silent_push", + }; + expect(Value.Check(EnvironmentSummarySchema, node)).toBe(true); + expect(Value.Check(EnvironmentSummarySchema, { ...node, lastDisconnectedAtMs: -1 })).toBe( + false, + ); + expect(Value.Check(EnvironmentSummarySchema, { ...node, lastSeenReason: "" })).toBe(false); + }); + it("keeps desktop app launch requests, results, and projected ids closed", () => { expect(validateWorkerDesktopLaunchParams({ environmentId: "worker:one", app: "browser" })).toBe( true, diff --git a/packages/gateway-protocol/src/schema/environments.ts b/packages/gateway-protocol/src/schema/environments.ts index 5b66cbbf81e..259ba73273f 100644 --- a/packages/gateway-protocol/src/schema/environments.ts +++ b/packages/gateway-protocol/src/schema/environments.ts @@ -71,6 +71,10 @@ function createEnvironmentSummarySchema() { status: EnvironmentStatusSchema, platform: Type.Optional(NonEmptyString), sessionHost: Type.Optional(Type.Boolean()), + lastConnectedAtMs: Type.Optional(Type.Integer({ minimum: 0 })), + lastDisconnectedAtMs: Type.Optional(Type.Integer({ minimum: 0 })), + lastSeenAtMs: Type.Optional(Type.Integer({ minimum: 0 })), + lastSeenReason: Type.Optional(NonEmptyString), trust: Type.Optional(EnvironmentTrustSchema), capabilities: Type.Optional(Type.Array(NonEmptyString)), desktop: Type.Optional(Type.Boolean()), diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index d0c2ec7443b..dc4e6508dce 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -81,6 +81,10 @@ export type EnvironmentSummary = { status: "available" | "unavailable" | "starting" | "stopping" | "error"; platform?: string; sessionHost?: boolean; + lastConnectedAtMs?: number; + lastDisconnectedAtMs?: number; + lastSeenAtMs?: number; + lastSeenReason?: string; trust?: "persistent" | "disposable"; capabilities?: string[]; worker?: WorkerEnvironmentMetadata; diff --git a/src/gateway/node-catalog.test.ts b/src/gateway/node-catalog.test.ts index d3ab71be898..8a96af388df 100644 --- a/src/gateway/node-catalog.test.ts +++ b/src/gateway/node-catalog.test.ts @@ -262,6 +262,7 @@ describe("gateway/node-catalog", () => { paired: true, connected: false, }); + expect(getKnownNode(catalog, "mac-1")?.lastConnectedAtMs).toBeUndefined(); }); it("uses the newest durable last-seen source for offline nodes", () => { @@ -282,6 +283,7 @@ describe("gateway/node-catalog", () => { caps: [], commands: [], lastConnectedAtMs: 200, + lastDisconnectedAtMs: 250, lastSeenAtMs: 100, lastSeenReason: "bg_app_refresh", approvedAtMs: 11, @@ -291,6 +293,8 @@ describe("gateway/node-catalog", () => { }); const node = getKnownNode(catalog, "ios-1"); + expect(node?.lastConnectedAtMs).toBe(200); + expect(node?.lastDisconnectedAtMs).toBe(250); expect(node?.lastSeenAtMs).toBe(300); expect(node?.lastSeenReason).toBe("silent_push"); }); @@ -302,6 +306,8 @@ describe("gateway/node-catalog", () => { pairedNode({ caps: ["system"], approvedAtMs: 123, + lastConnectedAtMs: 0, + lastDisconnectedAtMs: 500, }), ], connectedNodes: [ @@ -326,6 +332,8 @@ describe("gateway/node-catalog", () => { const node = getKnownNode(catalog, "mac-1"); expect(node?.caps).toEqual(["canvas"]); expect(node?.commands).toEqual(["canvas.snapshot"]); + expect(node?.lastConnectedAtMs).toBe(1); + expect(node?.lastDisconnectedAtMs).toBeUndefined(); expect(node?.connected).toBe(true); }); diff --git a/src/gateway/node-catalog.ts b/src/gateway/node-catalog.ts index 86afd962ec5..ddbfb3566df 100644 --- a/src/gateway/node-catalog.ts +++ b/src/gateway/node-catalog.ts @@ -42,6 +42,7 @@ type KnownNodeApprovedSource = { permissions?: Record; approvedAtMs?: number; lastConnectedAtMs?: number; + lastDisconnectedAtMs?: number; lastSeenAtMs?: number; lastSeenReason?: string; }; @@ -133,6 +134,7 @@ function buildApprovedNodeSource(entry: PairedDeviceNode): KnownNodeApprovedSour permissions: entry.permissions, approvedAtMs: entry.approvedAtMs, lastConnectedAtMs: entry.lastConnectedAtMs, + lastDisconnectedAtMs: entry.lastDisconnectedAtMs, lastSeenAtMs: entry.lastSeenAtMs, lastSeenReason: entry.lastSeenReason, }; @@ -178,6 +180,11 @@ function resolveCurrentPendingNodePairing(params: { : undefined; } +function maxDefinedTimestamp(...values: Array): number | undefined { + const defined = values.filter((value): value is number => value !== undefined); + return defined.length > 0 ? Math.max(...defined) : undefined; +} + function resolveEffectiveLastSeen(params: { live?: NodeSession; devicePairing?: KnownNodeDevicePairingSource; @@ -222,6 +229,11 @@ function buildEffectiveKnownNode(entry: { }): NodeListNode { const { nodeId, devicePairing, nodePairing, pendingNodePairing, live, sessionHost } = entry; const lastSeen = resolveEffectiveLastSeen({ live, devicePairing, nodePairing }); + const lastConnectedAtMs = maxDefinedTimestamp( + nodePairing?.lastConnectedAtMs, + live?.connectedAtMs, + ); + const lastDisconnectedAtMs = live ? undefined : nodePairing?.lastDisconnectedAtMs; return { nodeId, displayName: firstNormalizedString( @@ -299,6 +311,8 @@ function buildEffectiveKnownNode(entry: { pendingDeclaredCommands: pendingNodePairing?.commands, pendingDeclaredPermissions: pendingNodePairing?.permissions, connectedAtMs: live?.connectedAtMs, + lastConnectedAtMs, + lastDisconnectedAtMs, lastActiveAtMs: live?.lastActiveAtMs, presenceUpdatedAtMs: live?.presenceUpdatedAtMs, lastSeenAtMs: lastSeen.lastSeenAtMs, diff --git a/src/gateway/server-methods/environments.test.ts b/src/gateway/server-methods/environments.test.ts index 144f0842a59..8b55c9f5945 100644 --- a/src/gateway/server-methods/environments.test.ts +++ b/src/gateway/server-methods/environments.test.ts @@ -58,28 +58,29 @@ function mockContext( environmentId: string, onCleanupError?: (error: unknown) => void, ) => Promise = vi.fn(async () => workerRecord({ state: "destroyed" })), + connectedNodes: unknown[] = [ + { + nodeId: "node-live", + connId: "conn-live", + displayName: "Live Node", + platform: "ios", + caps: ["camera"], + commands: ["system.run"], + workerRuns: { + bundleHash: "a".repeat(64), + openclawVersion: "2026.8.12", + protocolFeatures: ["worker-heartbeat-v1"], + }, + connectedAtMs: 123, + }, + ], ) { return { logGateway: { warn: vi.fn(), }, nodeRegistry: { - listConnectedForPairingStates: () => [ - { - nodeId: "node-live", - connId: "conn-live", - displayName: "Live Node", - platform: "ios", - caps: ["camera"], - commands: ["system.run"], - workerRuns: { - bundleHash: "a".repeat(64), - openclawVersion: "2026.8.12", - protocolFeatures: ["worker-heartbeat-v1"], - }, - connectedAtMs: 123, - }, - ], + listConnectedForPairingStates: () => connectedNodes, }, workerEnvironmentService, getRuntimeConfig: () => ({ @@ -167,13 +168,19 @@ async function callEnvironmentMethod( environmentId: string, onCleanupError?: (error: unknown) => void, ) => Promise; + connectedNodes?: unknown[]; } = {}, ) { const respond = vi.fn(); await environmentsHandlers[method]?.({ params: params as Record, respond, - context: mockContext(options.service, options.reconcileActive, options.forceDestroyEnvironment), + context: mockContext( + options.service, + options.reconcileActive, + options.forceDestroyEnvironment, + options.connectedNodes, + ), } as never); const call = respond.mock.calls.at(0); if (call === undefined) { @@ -234,6 +241,9 @@ describe("environment gateway methods", () => { status: "available", platform: "ios", sessionHost: true, + lastConnectedAtMs: 123, + lastSeenAtMs: 123, + lastSeenReason: "connect", trust: "persistent", capabilities: ["camera", "system.run"], }, @@ -262,6 +272,53 @@ describe("environment gateway methods", () => { expect(environments.find((entry) => entry.id === "node:node-offline")?.sessionHost).toBe(false); }); + it("preserves never-connected and clean-disconnect history for offline nodes", async () => { + vi.mocked(listNodePairing).mockResolvedValue({ + paired: [ + { + nodeId: "node-never", + displayName: "Never Node", + commands: ["system.run"], + lastSeenAtMs: 2_000, + lastSeenReason: "device-token-auth", + }, + { + nodeId: "node-lost", + displayName: "Lost Node", + commands: ["system.run"], + lastConnectedAtMs: 1_000, + lastDisconnectedAtMs: 4_000, + lastSeenAtMs: 3_000, + lastSeenReason: "silent_push", + }, + ], + } as never); + + const [ok, payload] = await callEnvironmentMethod( + "environments.list", + {}, + { connectedNodes: [] }, + ); + + expect(ok).toBe(true); + const environments = (payload as { environments: Array> }).environments; + expect(environments.find((entry) => entry.id === "node:node-never")).toMatchObject({ + status: "unavailable", + lastSeenAtMs: 2_000, + lastSeenReason: "device-token-auth", + }); + expect(environments.find((entry) => entry.id === "node:node-never")).not.toHaveProperty( + "lastConnectedAtMs", + ); + expect(environments.find((entry) => entry.id === "node:node-lost")).toMatchObject({ + status: "unavailable", + lastConnectedAtMs: 1_000, + lastDisconnectedAtMs: 4_000, + lastSeenAtMs: 3_000, + lastSeenReason: "silent_push", + }); + }); + it("marks only connected, advertised, and explicitly allowed nodes as desktop sources", async () => { const context = mockContext(); context.getRuntimeConfig = () => @@ -420,6 +477,9 @@ describe("environment gateway methods", () => { status: "available", platform: "ios", sessionHost: true, + lastConnectedAtMs: 123, + lastSeenAtMs: 123, + lastSeenReason: "connect", trust: "persistent", capabilities: ["camera", "system.run"], }); diff --git a/src/gateway/server-methods/environments.ts b/src/gateway/server-methods/environments.ts index 2b6858d1161..56565f19951 100644 --- a/src/gateway/server-methods/environments.ts +++ b/src/gateway/server-methods/environments.ts @@ -88,6 +88,12 @@ function summarizeNodeEnvironment( status: node.connected ? "available" : "unavailable", ...(platform ? { platform } : {}), sessionHost: node.connected === true && node.sessionHost === true, + ...(node.lastConnectedAtMs !== undefined ? { lastConnectedAtMs: node.lastConnectedAtMs } : {}), + ...(node.lastDisconnectedAtMs !== undefined + ? { lastDisconnectedAtMs: node.lastDisconnectedAtMs } + : {}), + ...(node.lastSeenAtMs !== undefined ? { lastSeenAtMs: node.lastSeenAtMs } : {}), + ...(node.lastSeenReason ? { lastSeenReason: node.lastSeenReason } : {}), trust: "persistent", ...(desktop ? { desktop: true } : {}), ...(capabilities.length > 0 ? { capabilities } : {}), diff --git a/src/gateway/server/ws-connection.test.ts b/src/gateway/server/ws-connection.test.ts index 258ce66ec50..667e9efce6a 100644 --- a/src/gateway/server/ws-connection.test.ts +++ b/src/gateway/server/ws-connection.test.ts @@ -16,6 +16,7 @@ const { attachWorkerWsMessageHandlerMock, broadcastPresenceSnapshotMock, cleanupTalkConnectionMock, + recordPairedNodeDisconnectionMock, touchPresenceMock, upsertPresenceMock, } = vi.hoisted(() => ({ @@ -23,6 +24,7 @@ const { attachWorkerWsMessageHandlerMock: vi.fn((_params: unknown) => vi.fn()), broadcastPresenceSnapshotMock: vi.fn(), cleanupTalkConnectionMock: vi.fn(), + recordPairedNodeDisconnectionMock: vi.fn(async () => ({ recorded: true })), touchPresenceMock: vi.fn(), upsertPresenceMock: vi.fn(), })); @@ -33,6 +35,9 @@ vi.mock("./ws-connection/message-handler.js", () => ({ vi.mock("./ws-connection/worker-connection.js", () => ({ attachWorkerWsMessageHandler: attachWorkerWsMessageHandlerMock, })); +vi.mock("../../infra/device-pairing-node.js", () => ({ + recordPairedNodeDisconnection: recordPairedNodeDisconnectionMock, +})); vi.mock("../../infra/system-presence.js", () => ({ touchPresence: touchPresenceMock, upsertPresence: upsertPresenceMock, @@ -99,6 +104,8 @@ describe("attachGatewayWsConnectionHandler", () => { attachWorkerWsMessageHandlerMock.mockClear(); broadcastPresenceSnapshotMock.mockReset(); cleanupTalkConnectionMock.mockReset(); + recordPairedNodeDisconnectionMock.mockReset(); + recordPairedNodeDisconnectionMock.mockResolvedValue({ recorded: true }); touchPresenceMock.mockReset(); upsertPresenceMock.mockReset(); }); @@ -362,6 +369,7 @@ describe("attachGatewayWsConnectionHandler", () => { it("terminates a connection after one missed protocol pong", async () => { vi.useFakeTimers(); const unregister = vi.fn(); + const get = vi.fn(() => undefined); const clients = new Set(); const socket = Object.assign(createGatewayWsTestSocket({ ping: true }), { terminate: vi.fn(), @@ -374,7 +382,9 @@ describe("attachGatewayWsConnectionHandler", () => { socket, options: { buildRequestContext: () => - createGatewayWsTestRequestContext({ nodeRegistry: { unregister } }) as never, + createGatewayWsTestRequestContext({ + nodeRegistry: { get, unregister } as never, + }) as never, }, }); const handlerParams = passed as { @@ -560,23 +570,72 @@ describe("attachGatewayWsConnectionHandler", () => { ); }); - it("skips node presence disconnects for stale reconnected sockets", async () => { - const unregister = vi.fn(() => null); - const { socket } = attachGatewayWsForTest({ - attach: attachGatewayWsConnectionHandler, + it("records disconnect history for the current node connection", async () => { + const unregister = vi.fn(() => "node-1"); + const get = vi.fn(); + const { socket, passed } = await connectTestWs({ options: { refreshHealthSnapshot: vi.fn(), buildRequestContext: () => - createGatewayWsTestRequestContext({ nodeRegistry: { unregister } }) as never, + createGatewayWsTestRequestContext({ + nodeRegistry: { get, unregister } as never, + }) as never, }, }); - await waitForLazyMessageHandler(); + const handler = passed as { + connId: string; + setClient: (client: unknown) => boolean; + }; + get.mockReturnValue({ + nodeId: "node-1", + connId: handler.connId, + connectedAtMs: 1_000, + pairingGeneration: "generation-1", + }); + expect( + handler.setClient({ + socket, + connect: { + role: "node", + client: { id: "openclaw-macos", mode: "node" }, + device: { id: "node-1" }, + }, + connId: handler.connId, + presenceKey: "node-1", + usesSharedGatewayAuth: false, + }), + ).toBe(true); - const passed = firstAttachedHandlerParams() as { + socket.emit("close", 1000, Buffer.from("done")); + + await vi.waitFor(() => expect(unregister).toHaveBeenCalledOnce()); + expect(get).toHaveBeenCalledWith("node-1"); + await vi.waitFor(() => expect(recordPairedNodeDisconnectionMock).toHaveBeenCalledOnce()); + expect(recordPairedNodeDisconnectionMock).toHaveBeenCalledWith({ + nodeId: "node-1", + connectedAtMs: 1_000, + disconnectedAtMs: expect.any(Number), + expectedPairingGeneration: { nodeId: "node-1", key: "generation-1" }, + }); + }); + + it("skips node presence disconnects for stale reconnected sockets", async () => { + const unregister = vi.fn(() => null); + const get = vi.fn(() => undefined); + const { socket, passed } = await connectTestWs({ + options: { + refreshHealthSnapshot: vi.fn(), + buildRequestContext: () => + createGatewayWsTestRequestContext({ + nodeRegistry: { get, unregister } as never, + }) as never, + }, + }); + const handler = passed as { setClient: (client: unknown) => boolean; }; expect( - passed.setClient({ + handler.setClient({ socket, connect: { role: "node", @@ -591,7 +650,8 @@ describe("attachGatewayWsConnectionHandler", () => { socket.emit("close", 1000, Buffer.from("stale")); - expect(unregister).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(unregister).toHaveBeenCalledTimes(1)); + expect(recordPairedNodeDisconnectionMock).not.toHaveBeenCalled(); expect(upsertPresenceMock).not.toHaveBeenCalled(); expect(broadcastPresenceSnapshotMock).not.toHaveBeenCalled(); }); diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index d60e08733be..3b3cf27e602 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -5,6 +5,7 @@ import type { RawData, WebSocket, WebSocketServer } from "ws"; import { WORKER_PROTOCOL_MAX_PAYLOAD_BYTES } from "../../../packages/gateway-protocol/src/index.js"; import { GATEWAY_STARTUP_PENDING_CLOSE_CAUSE } from "../../../packages/gateway-protocol/src/startup-unavailable.js"; import { getRuntimeConfig } from "../../config/io.js"; +import { recordPairedNodeDisconnection } from "../../infra/device-pairing-node.js"; import { touchPresence, upsertPresence } from "../../infra/system-presence.js"; import { logRejectedLargePayload } from "../../logging/diagnostic-payload.js"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; @@ -493,7 +494,25 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti // terminal.attach until their reaper fires. context.terminalSessions?.handleDisconnect(connId); let currentDisconnectedNodeId: string | null = null; + let disconnectedNodeHistory: + | { + nodeId: string; + connectedAtMs: number; + disconnectedAtMs: number; + pairingGeneration: string; + } + | undefined; if (client?.connect?.role === "node") { + const nodeId = client.connect.device?.id ?? client.connect.client.id; + const nodeSession = context.nodeRegistry.get(nodeId); + if (nodeSession?.connId === connId && nodeSession.pairingGeneration) { + disconnectedNodeHistory = { + nodeId: nodeSession.nodeId, + connectedAtMs: nodeSession.connectedAtMs, + disconnectedAtMs: Date.now(), + pairingGeneration: nodeSession.pairingGeneration, + }; + } // Retire I/O immediately, but keep the client revocable until admitted // lifecycle work drains; pairing/token removal must still fence it. retainClientUntilNodeDrain = true; @@ -508,6 +527,26 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti } } currentDisconnectedNodeId = context.nodeRegistry.unregister(connId); + if ( + disconnectedNodeHistory && + currentDisconnectedNodeId === disconnectedNodeHistory.nodeId + ) { + try { + await recordPairedNodeDisconnection({ + nodeId: disconnectedNodeHistory.nodeId, + connectedAtMs: disconnectedNodeHistory.connectedAtMs, + disconnectedAtMs: disconnectedNodeHistory.disconnectedAtMs, + expectedPairingGeneration: { + nodeId: disconnectedNodeHistory.nodeId, + key: disconnectedNodeHistory.pairingGeneration, + }, + }); + } catch (error) { + logGateway.warn( + `failed to record node disconnect for ${disconnectedNodeHistory.nodeId}: ${formatForLog(error)}`, + ); + } + } } finally { retainClientUntilNodeDrain = false; } diff --git a/src/gateway/watch-node-http.test.ts b/src/gateway/watch-node-http.test.ts index 5664e7104ff..62f159ba7b4 100644 --- a/src/gateway/watch-node-http.test.ts +++ b/src/gateway/watch-node-http.test.ts @@ -393,8 +393,16 @@ describe("watch node HTTP transport", () => { }); it("requires an authenticated disconnect and emits one lifecycle teardown", async () => { - const { identity, issued, nodeRegistry, connectedNodes, disconnectedNodes, runtime, baseUrl } = - await createWatchNodeFixture("openclaw-watch-node-disconnect-"); + const { + baseDir, + identity, + issued, + nodeRegistry, + connectedNodes, + disconnectedNodes, + runtime, + baseUrl, + } = await createWatchNodeFixture("openclaw-watch-node-disconnect-"); const connectResponse = await connectWatchNode({ baseUrl, @@ -424,9 +432,17 @@ describe("watch node HTTP transport", () => { expect(disconnectResponse.status).toBe(200); await expect(readJson(disconnectResponse)).resolves.toEqual({ ok: true }); expect(nodeRegistry.get(identity.deviceId)).toBeUndefined(); - expect(disconnectedNodes).toEqual([ - { nodeId: identity.deviceId, reason: "watch disconnected" }, - ]); + await vi.waitFor(() => + expect(disconnectedNodes).toEqual([ + { nodeId: identity.deviceId, reason: "watch disconnected" }, + ]), + ); + await vi.waitFor(async () => { + const paired = (await listNodePairing(baseDir)).paired.find( + (entry) => entry.nodeId === identity.deviceId, + ); + expect(paired?.lastDisconnectedAtMs).toEqual(expect.any(Number)); + }); const repeatedDisconnect = await fetch(`${baseUrl}/disconnect`, { method: "POST", @@ -499,9 +515,11 @@ describe("watch node HTTP transport", () => { : nodeRegistry.sendEvent(identity.deviceId, "node.invoke.request", payload); expect(delivered).toBe(false); expect(nodeRegistry.get(identity.deviceId)).toBeUndefined(); - expect(disconnectedNodes).toEqual([ - { nodeId: identity.deviceId, reason: "event delivery failed" }, - ]); + await vi.waitFor(() => + expect(disconnectedNodes).toEqual([ + { nodeId: identity.deviceId, reason: "event delivery failed" }, + ]), + ); await expect(pollFailure).resolves.toBe("ECONNRESET"); expect(nodeRegistry.sendEvent(identity.deviceId, "node.invoke.request", payload)).toBe( false, @@ -816,10 +834,12 @@ describe("watch node HTTP transport", () => { }); runtime.disconnectSessionsForDevice(identity.deviceId, { role: "node" }); expect(nodeRegistry.get(identity.deviceId)).toBeUndefined(); - expect(disconnectedNodes).toContainEqual({ - nodeId: identity.deviceId, - reason: "device-token-revoked", - }); + await vi.waitFor(() => + expect(disconnectedNodes).toContainEqual({ + nodeId: identity.deviceId, + reason: "device-token-revoked", + }), + ); const invalidatedPollResponse = await fetch(`${baseUrl}/poll`, { method: "POST", headers: { authorization: `Bearer ${String(reconnected.sessionToken)}` }, @@ -875,10 +895,12 @@ describe("watch node HTTP transport", () => { nodeRegistry.sendEventRaw(identity.deviceId, "node.invoke.request", oversizedPayload), ).toBe(false); expect(nodeRegistry.get(identity.deviceId)).toBeUndefined(); - expect(disconnectedNodes).toContainEqual({ - nodeId: identity.deviceId, - reason: "event payload too large", - }); + await vi.waitFor(() => + expect(disconnectedNodes).toContainEqual({ + nodeId: identity.deviceId, + reason: "event payload too large", + }), + ); runtime.close(); expect(nodeRegistry.get(identity.deviceId)).toBeUndefined(); diff --git a/src/gateway/watch-node-http.ts b/src/gateway/watch-node-http.ts index 9974434dd26..4f3c7dc74fb 100644 --- a/src/gateway/watch-node-http.ts +++ b/src/gateway/watch-node-http.ts @@ -33,6 +33,7 @@ import { releaseNodePairingCleanupClaim, requestNodePairing, recordPairedNodeConnection, + recordPairedNodeDisconnection, type RequestNodePairingResult, } from "../infra/device-pairing-node.js"; import { @@ -320,13 +321,40 @@ export function createWatchNodeHttpRuntime(options: WatchNodeHttpRuntimeOptions) } session.waiter = undefined; } + const nodeSession = options.nodeRegistry.get(session.nodeId); + const disconnectHistory = + nodeSession?.connId === session.connId && nodeSession.pairingGeneration + ? { + nodeId: nodeSession.nodeId, + connectedAtMs: nodeSession.connectedAtMs, + pairingGeneration: nodeSession.pairingGeneration, + } + : undefined; const disconnectedNodeId = options.nodeRegistry.unregister(session.connId); if (disconnectedNodeId) { - try { - options.onNodeDisconnected?.(disconnectedNodeId, reason); - } catch (error) { - options.onError?.("watch node disconnect cleanup failed", error); - } + void (async () => { + try { + if (disconnectHistory && disconnectHistory.nodeId === disconnectedNodeId) { + await recordPairedNodeDisconnection({ + nodeId: disconnectHistory.nodeId, + connectedAtMs: disconnectHistory.connectedAtMs, + disconnectedAtMs: now(), + expectedPairingGeneration: { + nodeId: disconnectHistory.nodeId, + key: disconnectHistory.pairingGeneration, + }, + baseDir: options.pairingBaseDir, + }); + } + } catch (error) { + options.onError?.("watch node disconnect persistence failed", error); + } + try { + options.onNodeDisconnected?.(disconnectedNodeId, reason); + } catch (error) { + options.onError?.("watch node disconnect cleanup failed", error); + } + })(); } }; diff --git a/src/infra/device-pairing-node.test.ts b/src/infra/device-pairing-node.test.ts index c84344fa0b3..7b3ab2bedff 100644 --- a/src/infra/device-pairing-node.test.ts +++ b/src/infra/device-pairing-node.test.ts @@ -9,6 +9,7 @@ import { finalizeNodePairingCleanupClaim, listNodePairing, recordPairedNodeConnection, + recordPairedNodeDisconnection, releaseNodePairingCleanupClaim, renamePairedNode, requestNodePairing, @@ -673,6 +674,81 @@ describe("node surface approvals", () => { }); }); + test("records and clears generation-bound node disconnect history", async () => { + await withNodePairingDir(async (baseDir) => { + await setupPairedNode(baseDir); + const generation = resolveNodePairingGeneration(await getPairedDevice("node-1", baseDir)); + if (!generation) { + throw new Error("expected node pairing generation"); + } + + await expect( + recordPairedNodeConnection("node-1", 1_000, baseDir, generation), + ).resolves.toEqual({ recorded: true, firstConnection: true }); + await expect( + recordPairedNodeDisconnection({ + nodeId: "node-1", + connectedAtMs: 1_000, + disconnectedAtMs: 1_500, + expectedPairingGeneration: generation, + baseDir, + }), + ).resolves.toEqual({ recorded: true }); + expect(await findPairedNode("node-1", baseDir)).toMatchObject({ + lastConnectedAtMs: 1_000, + lastDisconnectedAtMs: 1_500, + }); + + await expect( + recordPairedNodeConnection("node-1", 2_000, baseDir, generation), + ).resolves.toEqual({ recorded: true, firstConnection: false }); + expect((await findPairedNode("node-1", baseDir))?.lastDisconnectedAtMs).toBeUndefined(); + await expect( + recordPairedNodeDisconnection({ + nodeId: "node-1", + connectedAtMs: 1_000, + disconnectedAtMs: 2_500, + expectedPairingGeneration: generation, + baseDir, + }), + ).resolves.toEqual({ recorded: false }); + expect((await findPairedNode("node-1", baseDir))?.lastDisconnectedAtMs).toBeUndefined(); + }); + }); + + test("rejects disconnect history from a retired pairing generation", async () => { + await withNodePairingDir(async (baseDir) => { + await setupPairedNode(baseDir); + const previousGeneration = resolveNodePairingGeneration( + await getPairedDevice("node-1", baseDir), + ); + if (!previousGeneration) { + throw new Error("expected initial node pairing generation"); + } + await recordPairedNodeConnection("node-1", 1_000, baseDir, previousGeneration); + const pending = await requestNodePairing( + { nodeId: "node-1", platform: "darwin", commands: ["system.run", "system.which"] }, + baseDir, + ); + await approveNodePairing( + pending.request.requestId, + { callerScopes: ["operator.pairing", "operator.admin"] }, + baseDir, + ); + + await expect( + recordPairedNodeDisconnection({ + nodeId: "node-1", + connectedAtMs: 1_000, + disconnectedAtMs: 1_500, + expectedPairingGeneration: previousGeneration, + baseDir, + }), + ).resolves.toEqual({ recorded: false }); + expect((await findPairedNode("node-1", baseDir))?.lastDisconnectedAtMs).toBeUndefined(); + }); + }); + test("serializes connection metadata with locked node-surface mutations", async () => { await withNodePairingDir(async (baseDir) => { await setupPairedNode(baseDir); diff --git a/src/infra/device-pairing-node.ts b/src/infra/device-pairing-node.ts index fdb97d889a6..b94d94341d6 100644 --- a/src/infra/device-pairing-node.ts +++ b/src/infra/device-pairing-node.ts @@ -79,6 +79,7 @@ export type PairedDeviceNode = NodeDeclaredSurface & { createdAtMs: number; approvedAtMs: number; lastConnectedAtMs?: number; + lastDisconnectedAtMs?: number; lastSeenAtMs?: number; lastSeenReason?: string; }; @@ -187,6 +188,7 @@ function toPairedNode( createdAtMs: surface.createdAtMs, approvedAtMs: surface.approvedAtMs, lastConnectedAtMs: surface.lastConnectedAtMs, + lastDisconnectedAtMs: surface.lastDisconnectedAtMs, lastSeenAtMs: device.lastSeenAtMs, lastSeenReason: device.lastSeenReason, }; @@ -698,12 +700,17 @@ export async function recordPairedNodeConnection( // both claim the same node's first connection and schedule duplicate alerts. const firstConnection = device.nodeSurface.lastConnectedAtMs === undefined; const previousConnectedAtMs = device.nodeSurface.lastConnectedAtMs ?? connectedAtMs; + const lastConnectedAtMs = Math.max(previousConnectedAtMs, connectedAtMs); + const clearsDisconnect = + device.nodeSurface.lastDisconnectedAtMs !== undefined && + connectedAtMs > device.nodeSurface.lastDisconnectedAtMs; return { value: { recorded: true, firstConnection }, persist: true, nodeSurface: { ...device.nodeSurface, - lastConnectedAtMs: Math.max(previousConnectedAtMs, connectedAtMs), + lastConnectedAtMs, + ...(clearsDisconnect ? { lastDisconnectedAtMs: undefined } : {}), }, }; }, @@ -715,6 +722,50 @@ export async function recordPairedNodeConnection( }); } +type RecordPairedNodeDisconnectionResult = { recorded: boolean }; + +/** Persist the end of the exact successful node connection that just retired. */ +export async function recordPairedNodeDisconnection(params: { + nodeId: string; + connectedAtMs: number; + disconnectedAtMs: number; + expectedPairingGeneration: NodePairingGeneration; + baseDir?: string; +}): Promise { + return await withPairedDeviceRecords(params.baseDir, () => { + const value = updatePairedDeviceNodeSurfaceInTransaction( + params.nodeId, + params.baseDir, + (device) => { + const currentPairingGeneration = resolveNodePairingGeneration(device); + if ( + !device?.nodeSurface || + params.expectedPairingGeneration.nodeId !== device.deviceId || + currentPairingGeneration?.key !== params.expectedPairingGeneration.key || + device.nodeSurface.lastConnectedAtMs !== params.connectedAtMs || + params.disconnectedAtMs < params.connectedAtMs + ) { + return { value: { recorded: false }, persist: false }; + } + return { + value: { recorded: true }, + persist: true, + nodeSurface: { + ...device.nodeSurface, + lastDisconnectedAtMs: Math.max( + device.nodeSurface.lastDisconnectedAtMs ?? params.disconnectedAtMs, + params.disconnectedAtMs, + ), + }, + }; + }, + ); + // The row-scoped transaction owns cross-process generation and connection + // validation; the shared lock prevents stale full-snapshot replay. + return { value, persist: false }; + }); +} + /** Rename a paired node display name while preserving approval metadata. */ export async function renamePairedNode( nodeId: string, diff --git a/src/infra/device-pairing.types.ts b/src/infra/device-pairing.types.ts index 7b2a45532ee..2b543389978 100644 --- a/src/infra/device-pairing.types.ts +++ b/src/infra/device-pairing.types.ts @@ -85,6 +85,7 @@ export type PairedDeviceNodeSurface = { createdAtMs: number; approvedAtMs: number; lastConnectedAtMs?: number; + lastDisconnectedAtMs?: number; }; /** diff --git a/src/shared/node-list-types.ts b/src/shared/node-list-types.ts index 6fb2930275f..ec9d82e0b74 100644 --- a/src/shared/node-list-types.ts +++ b/src/shared/node-list-types.ts @@ -30,6 +30,8 @@ export type NodeListNode = { paired?: boolean; connected?: boolean; connectedAtMs?: number; + lastConnectedAtMs?: number; + lastDisconnectedAtMs?: number; lastActiveAtMs?: number; presenceUpdatedAtMs?: number; active?: boolean; diff --git a/ui/src/e2e/new-session-page.environment-metadata.e2e.test.ts b/ui/src/e2e/new-session-page.environment-metadata.e2e.test.ts index 3f0d324125f..f5ad728e5bb 100644 --- a/ui/src/e2e/new-session-page.environment-metadata.e2e.test.ts +++ b/ui/src/e2e/new-session-page.environment-metadata.e2e.test.ts @@ -76,8 +76,10 @@ suite.define(() => { { id: "node:offline-rich", type: "node", - status: "available", - sessionHost: true, + status: "unavailable", + sessionHost: false, + lastConnectedAtMs: 1_000, + lastDisconnectedAtMs: 4_000, capabilities: ["camera", "screen"], }, { @@ -130,7 +132,12 @@ suite.define(() => { expect( await place.locator('[data-value="gateway"] .new-session-page__menu-fact').count(), ).toBe(0); - expect(await place.locator('[data-value="node:offline-rich"]').count()).toBe(0); + const offline = place.locator('[data-value="node:offline-rich"]'); + expect(await offline.count()).toBe(1); + expect(await offline.isDisabled()).toBe(true); + expect( + (await offline.locator(".new-session-page__menu-fact").first().textContent()) ?? "", + ).toMatch(/^Offline for /); expect(await place.locator('[data-value="node:non-exec-rich"]').count()).toBe(0); const visibleCopy = ((await place.textContent()) ?? "").toLowerCase(); diff --git a/ui/src/e2e/new-session-page.places-live.e2e.test.ts b/ui/src/e2e/new-session-page.places-live.e2e.test.ts index 1a8fc50fc68..19585ffabf9 100644 --- a/ui/src/e2e/new-session-page.places-live.e2e.test.ts +++ b/ui/src/e2e/new-session-page.places-live.e2e.test.ts @@ -39,6 +39,9 @@ suite.define(() => { }); it("refreshes destinations from gateway events while the picker stays open", async () => { + const lifecycleNowMs = Date.now(); + const disconnectedAtMs = lifecycleNowMs - 2 * 60_000; + const connectedAtMs = disconnectedAtMs - 3 * 60_000; const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block", @@ -124,11 +127,103 @@ suite.define(() => { await expect .poll(async () => (await gateway.getRequests("environments.list")).length) .toBeGreaterThan(environmentRequests); - await place.getByRole("button", { name: "New Mac" }).waitFor(); + const newMac = place.locator('[data-value="node:new-mac"]'); + await newMac.waitFor(); await place.getByRole("button", { name: "Local" }).waitFor(); await place.getByText("Your devices", { exact: true }).waitFor(); expect(await place.getAttribute("open")).not.toBeNull(); + const disconnectNodeRequests = (await gateway.getRequests("node.list")).length; + await gateway.setMethodResponse("node.list", { + nodes: [ + { + nodeId: "existing-mac", + displayName: "Existing Mac", + connected: true, + commands: ["system.run"], + }, + { + nodeId: "new-mac", + displayName: "New Mac", + connected: false, + commands: ["system.run"], + lastConnectedAtMs: connectedAtMs, + lastDisconnectedAtMs: disconnectedAtMs, + }, + ], + }); + await gateway.setMethodResponse("environments.list", { + environments: [ + { id: "gateway", type: "local", status: "available" }, + { id: "node:existing-mac", type: "node", status: "available" }, + { + id: "node:new-mac", + type: "node", + status: "unavailable", + lastConnectedAtMs: connectedAtMs, + lastDisconnectedAtMs: disconnectedAtMs, + }, + ], + profiles: [], + }); + await gateway.emitGatewayEvent("presence", { + presence: [ + { deviceId: "existing-mac", mode: "node", reason: "connect", ts: 3 }, + { deviceId: "new-mac", mode: "node", reason: "disconnect", ts: 4 }, + ], + }); + await expect + .poll(async () => (await gateway.getRequests("node.list")).length) + .toBeGreaterThan(disconnectNodeRequests); + await expect.poll(() => newMac.isDisabled()).toBe(true); + await expect + .poll(() => newMac.locator(".new-session-page__menu-fact").first().textContent()) + .toMatch(/^Offline for /); + await captureUiProof(page, "picker-device-offline.png"); + + const reconnectNodeRequests = (await gateway.getRequests("node.list")).length; + await gateway.setMethodResponse("node.list", { + nodes: [ + { + nodeId: "existing-mac", + displayName: "Existing Mac", + connected: true, + commands: ["system.run"], + }, + { + nodeId: "new-mac", + displayName: "New Mac", + connected: true, + commands: ["system.run"], + lastConnectedAtMs: lifecycleNowMs, + }, + ], + }); + await gateway.setMethodResponse("environments.list", { + environments: [ + { id: "gateway", type: "local", status: "available" }, + { id: "node:existing-mac", type: "node", status: "available" }, + { + id: "node:new-mac", + type: "node", + status: "available", + lastConnectedAtMs: lifecycleNowMs, + }, + ], + profiles: [], + }); + await gateway.emitGatewayEvent("presence", { + presence: [ + { deviceId: "existing-mac", mode: "node", reason: "connect", ts: 5 }, + { deviceId: "new-mac", mode: "node", reason: "connect", ts: 6 }, + ], + }); + await expect + .poll(async () => (await gateway.getRequests("node.list")).length) + .toBeGreaterThan(reconnectNodeRequests); + await expect.poll(() => newMac.isDisabled()).toBe(false); + await captureUiProof(page, "picker-device-reconnected.png"); + const refreshedEnvironmentRequests = (await gateway.getRequests("environments.list")).length; await gateway.setMethodResponse("environments.list", { environments: [], diff --git a/ui/src/e2e/new-session-page.projects-places.e2e.test.ts b/ui/src/e2e/new-session-page.projects-places.e2e.test.ts index 0c132d52198..4eec4807b86 100644 --- a/ui/src/e2e/new-session-page.projects-places.e2e.test.ts +++ b/ui/src/e2e/new-session-page.projects-places.e2e.test.ts @@ -742,7 +742,9 @@ suite.define(() => { await whereSelect.getByRole("button", { name: "Local" }).click(); await pollLocatorText(whereLabel).toBe("Local"); await whereTrigger.click(); - expect(await whereSelect.getByRole("button", { name: "Offline node" }).count()).toBe(0); + await expect + .poll(() => whereSelect.getByRole("button", { name: "Offline node" }).isDisabled()) + .toBe(true); await whereSelect.getByRole("button", { name: "MacBook" }).click(); await projectTrigger.click(); await projectSelect.getByRole("button", { name: "Browse folders" }).click(); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index a8291bf643a..c6b7ebfb1ca 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -719,6 +719,9 @@ export const en: TranslationMap = { folder: "Folder", folderPlaceholder: "Agent workspace", yourDevices: "Your devices", + neverConnected: "Never connected", + offlineFor: "Offline for {duration}", + lastSeen: "Last seen {time}", capabilityCamera: "Camera", capabilityLocation: "Location", capabilityTalk: "Talk", diff --git a/ui/src/pages/new-session/discovery.test.ts b/ui/src/pages/new-session/discovery.test.ts index 52a886f59db..8a7863f90f6 100644 --- a/ui/src/pages/new-session/discovery.test.ts +++ b/ui/src/pages/new-session/discovery.test.ts @@ -28,6 +28,30 @@ describe("readDraftNodes", () => { }, ]); }); + + it("keeps execution capability independent from connectivity", () => { + expect( + readDraftNodes([ + { + nodeId: "offline", + connected: false, + commands: ["system.run", "fs.listDir"], + }, + ]), + ).toEqual([ + { + nodeId: "offline", + displayName: "offline", + platform: undefined, + deviceFamily: undefined, + modelIdentifier: undefined, + remoteIp: undefined, + connected: false, + canExec: true, + canBrowse: false, + }, + ]); + }); }); describe("readDraftCloudProfiles", () => { it("keeps closed profile summaries in stable order", () => { @@ -82,6 +106,10 @@ describe("readDraftEnvironments", () => { type: "node", platform: " darwin ", sessionHost: false, + lastConnectedAtMs: 1_000.9, + lastDisconnectedAtMs: 2_000, + lastSeenAtMs: 1_500, + lastSeenReason: " silent_push ", trust: "persistent", capabilities: [" camera.snap ", 42, "custom.unknown", "system.run", null], }, @@ -100,6 +128,10 @@ describe("readDraftEnvironments", () => { type: "node", platform: "darwin", sessionHost: false, + lastConnectedAtMs: 1_000, + lastDisconnectedAtMs: 2_000, + lastSeenAtMs: 1_500, + lastSeenReason: "silent_push", trust: "persistent", capabilities: ["camera.snap", "custom.unknown", "system.run"], }, diff --git a/ui/src/pages/new-session/discovery.ts b/ui/src/pages/new-session/discovery.ts index b0ab434e1cb..bca797c0a0b 100644 --- a/ui/src/pages/new-session/discovery.ts +++ b/ui/src/pages/new-session/discovery.ts @@ -39,12 +39,22 @@ export type DraftEnvironment = { type: "local" | "node" | "worker"; platform?: string; sessionHost?: boolean; + lastConnectedAtMs?: number; + lastDisconnectedAtMs?: number; + lastSeenAtMs?: number; + lastSeenReason?: string; trust?: "persistent" | "disposable"; capabilities?: string[]; }; export type BrowserTarget = { nodeId: string; label: string }; +function normalizeTimestamp(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.trunc(value) + : undefined; +} + export function readDraftNodes(value: unknown): DraftNode[] { const rawNodes = Array.isArray(value) ? value : []; return rawNodes @@ -70,7 +80,7 @@ export function readDraftNodes(value: unknown): DraftNode[] { return []; } const connected = node.connected === true; - const canExec = connected && commands.includes("system.run"); + const canExec = commands.includes("system.run"); return [ { nodeId, @@ -81,7 +91,7 @@ export function readDraftNodes(value: unknown): DraftNode[] { remoteIp: normalizeOptionalString(node.remoteIp), connected, canExec, - canBrowse: canExec && commands.includes("fs.listDir"), + canBrowse: connected && canExec && commands.includes("fs.listDir"), }, ]; }) @@ -124,6 +134,10 @@ export function readDraftEnvironments(value: unknown): DraftEnvironment[] { type?: unknown; platform?: unknown; sessionHost?: unknown; + lastConnectedAtMs?: unknown; + lastDisconnectedAtMs?: unknown; + lastSeenAtMs?: unknown; + lastSeenReason?: unknown; trust?: unknown; capabilities?: unknown; }; @@ -138,6 +152,10 @@ export function readDraftEnvironments(value: unknown): DraftEnvironment[] { ? environment.trust : undefined; const capabilities = normalizeArrayBackedTrimmedStringList(environment.capabilities); + const lastConnectedAtMs = normalizeTimestamp(environment.lastConnectedAtMs); + const lastDisconnectedAtMs = normalizeTimestamp(environment.lastDisconnectedAtMs); + const lastSeenAtMs = normalizeTimestamp(environment.lastSeenAtMs); + const lastSeenReason = normalizeOptionalString(environment.lastSeenReason); return [ { id, @@ -146,6 +164,10 @@ export function readDraftEnvironments(value: unknown): DraftEnvironment[] { ...(typeof environment.sessionHost === "boolean" ? { sessionHost: environment.sessionHost } : {}), + ...(lastConnectedAtMs !== undefined ? { lastConnectedAtMs } : {}), + ...(lastDisconnectedAtMs !== undefined ? { lastDisconnectedAtMs } : {}), + ...(lastSeenAtMs !== undefined ? { lastSeenAtMs } : {}), + ...(lastSeenReason ? { lastSeenReason } : {}), ...(trust ? { trust } : {}), ...(capabilities ? { capabilities } : {}), }, diff --git a/ui/src/pages/new-session/draft-place-state.ts b/ui/src/pages/new-session/draft-place-state.ts index 5f3e6273b7e..984c36967aa 100644 --- a/ui/src/pages/new-session/draft-place-state.ts +++ b/ui/src/pages/new-session/draft-place-state.ts @@ -144,10 +144,14 @@ export class DraftPlaceState { return this.agents().find((agent) => normalizeAgentId(agent.id) === agentId); } - execNodes(): DraftNode[] { + executionNodes(): DraftNode[] { return this.nodesValue.filter((node) => node.canExec); } + execNodes(): DraftNode[] { + return this.executionNodes().filter((node) => node.connected); + } + execNodeReady(): boolean { return ( !this.execNodeValue || diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index b05663e90b1..abc42c0fca0 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -359,6 +359,7 @@ class NewSessionPage extends OpenClawLightDomElement { private renderPlaceChips() { const execNodes = this.place.execNodes(); + const executionNodes = this.place.executionNodes(); const cloudProfiles = catalog.isTarget(this.data) ? [] : this.gateway.cloudProfiles; const branches = this.place.repository.kind === "git" ? this.place.repository : null; const projects = catalog.isTarget(this.data) ? [] : this.browser.projects; @@ -372,7 +373,7 @@ class NewSessionPage extends OpenClawLightDomElement { isAdmin: this.place.isAdmin(), }); const whereState = resolveWhereChip({ - execNodes: this.place.isAdmin() ? execNodes : [], + execNodes: this.place.isAdmin() ? executionNodes : [], environments: this.place.isAdmin() ? this.gateway.environments : [], cloudProfiles: this.place.isAdmin() ? cloudProfiles : [], cloudProfileId: this.place.cloudProfileId, diff --git a/ui/src/pages/new-session/place-facts.ts b/ui/src/pages/new-session/place-facts.ts index bc8417805c2..5111759c493 100644 --- a/ui/src/pages/new-session/place-facts.ts +++ b/ui/src/pages/new-session/place-facts.ts @@ -1,4 +1,5 @@ import { t } from "../../i18n/index.ts"; +import { formatDurationCompact, formatRelativeTimestamp } from "../../lib/format.ts"; import { prettifyPlatform } from "../../lib/platform-label.ts"; import type { DraftEnvironment } from "./discovery.ts"; @@ -13,8 +14,43 @@ const CAPABILITY_FACT_KEYS = { voice: "newSession.capabilityVoice", } as const; -export function environmentMenuFacts(environment: DraftEnvironment | undefined): string[] { - const facts = environment?.platform ? [prettifyPlatform(environment.platform)] : []; +function environmentLifecycleFact(params: { + environment: DraftEnvironment | undefined; + connected: boolean; + nowMs: number; +}): string | undefined { + if (params.connected) { + return undefined; + } + const environment = params.environment; + if (environment?.lastConnectedAtMs === undefined) { + return t("newSession.neverConnected"); + } + if (environment.lastDisconnectedAtMs !== undefined) { + const duration = + formatDurationCompact(Math.max(0, params.nowMs - environment.lastDisconnectedAtMs)) ?? + t("common.justNow"); + return t("newSession.offlineFor", { duration }); + } + const lastSeenAtMs = environment.lastSeenAtMs ?? environment.lastConnectedAtMs; + return t("newSession.lastSeen", { + time: formatRelativeTimestamp(lastSeenAtMs), + }); +} + +export function environmentMenuFacts( + environment: DraftEnvironment | undefined, + options: { connected?: boolean; nowMs?: number } = {}, +): string[] { + const lifecycle = environmentLifecycleFact({ + environment, + connected: options.connected ?? true, + nowMs: options.nowMs ?? Date.now(), + }); + const facts = lifecycle ? [lifecycle] : []; + if (environment?.platform) { + facts.push(prettifyPlatform(environment.platform)); + } for (const capability of environment?.capabilities ?? []) { const family = capability.split(".", 1)[0]?.toLowerCase(); const key = family diff --git a/ui/src/pages/new-session/place-picker-sections.ts b/ui/src/pages/new-session/place-picker-sections.ts index 9632beeedde..ad6f79068b8 100644 --- a/ui/src/pages/new-session/place-picker-sections.ts +++ b/ui/src/pages/new-session/place-picker-sections.ts @@ -15,12 +15,13 @@ export function resolvePlacePickerSections(params: { : null; return { deviceNodes: params.execNodes.filter((node) => { - if (!node.connected || !node.canExec) { + if (!node.canExec) { return false; } if (environmentById === null || environmentById.size === 0) { - // Missing and empty catalogs preserve the established live-node fallback. - return true; + // Missing and empty catalogs preserve the established live-node fallback; + // offline rows need lifecycle facts from the environment read model. + return node.connected; } const environment = environmentById.get(`node:${node.nodeId}`); return environment?.type === "node"; @@ -28,7 +29,9 @@ export function resolvePlacePickerSections(params: { deviceFacts: new Map( params.execNodes.map((node) => [ node.nodeId, - environmentMenuFacts(environmentById?.get(`node:${node.nodeId}`)), + environmentMenuFacts(environmentById?.get(`node:${node.nodeId}`), { + connected: node.connected, + }), ]), ), cloudProfiles: [...params.cloudProfiles], diff --git a/ui/src/pages/new-session/where-chip.test.ts b/ui/src/pages/new-session/where-chip.test.ts index 5396bb1bfac..2f24459c27f 100644 --- a/ui/src/pages/new-session/where-chip.test.ts +++ b/ui/src/pages/new-session/where-chip.test.ts @@ -1,5 +1,5 @@ import { render } from "lit"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { readDraftEnvironments } from "./discovery.ts"; import { renderWhereChip, resolveWhereChip } from "./where-chip.ts"; @@ -164,4 +164,118 @@ describe("Where chip state", () => { expect(visibleCopy).not.toContain(clutter); } }); + + it("shows offline execution devices as disabled exceptional rows", () => { + const now = vi.spyOn(Date, "now").mockReturnValue(10_000); + try { + const state = resolveWhereChip({ + execNodes: [ + { + nodeId: "online", + displayName: "Online", + connected: true, + canExec: true, + canBrowse: true, + }, + { + nodeId: "never", + displayName: "Never", + connected: false, + canExec: true, + canBrowse: false, + }, + { + nodeId: "lost", + displayName: "Lost", + connected: false, + canExec: true, + canBrowse: false, + }, + { + nodeId: "legacy", + displayName: "Legacy", + connected: false, + canExec: true, + canBrowse: false, + }, + { + nodeId: "camera", + displayName: "Camera only", + connected: false, + canExec: false, + canBrowse: false, + }, + ], + environments: readDraftEnvironments([ + { id: "node:online", type: "node", platform: "darwin" }, + { + id: "node:never", + type: "node", + platform: "linux", + lastSeenAtMs: 2_000, + lastSeenReason: "device-token-auth", + }, + { + id: "node:lost", + type: "node", + platform: "linux", + lastConnectedAtMs: 1_000, + lastDisconnectedAtMs: 4_000, + lastSeenAtMs: 3_000, + }, + { + id: "node:legacy", + type: "node", + lastConnectedAtMs: 1_000, + lastSeenAtMs: 5_000, + }, + { id: "node:camera", type: "node" }, + ]), + cloudProfiles: [], + execNode: "", + cloudProfileId: "", + }); + const container = document.createElement("div"); + render( + renderWhereChip({ + state, + gatewayName: "", + cloudProfileId: "", + execNode: "", + worktreeAvailable: true, + submitting: false, + pendingCloud: false, + popoverOpen: true, + popoverHiding: false, + isAdmin: true, + onGuardTransition: () => undefined, + onPopoverShow: () => undefined, + onPopoverHide: () => undefined, + onPopoverAfterHide: () => undefined, + onSelectExecNode: () => undefined, + onSelectCloudProfile: () => undefined, + onConnectMachine: () => undefined, + }), + container, + ); + + const row = (id: string) => + container.querySelector(`[data-value="node:${id}"]`); + const facts = (id: string) => + [...(row(id)?.querySelectorAll(".new-session-page__menu-fact") ?? [])].map((entry) => + entry.textContent?.trim(), + ); + expect(row("online")?.disabled).toBe(false); + expect(facts("online")).toEqual(["macOS"]); + expect(row("never")?.disabled).toBe(true); + expect(facts("never")[0]).toBe("Never connected"); + expect(row("lost")?.disabled).toBe(true); + expect(facts("lost")[0]).toMatch(/^Offline for /); + expect(row("legacy")?.disabled).toBe(true); + expect(facts("legacy")[0]).toMatch(/^Last seen /); + expect(row("camera")).toBeNull(); + } finally { + now.mockRestore(); + } + }); }); diff --git a/ui/src/pages/new-session/where-chip.ts b/ui/src/pages/new-session/where-chip.ts index ab5ff2312dd..d93cd8466be 100644 --- a/ui/src/pages/new-session/where-chip.ts +++ b/ui/src/pages/new-session/where-chip.ts @@ -143,6 +143,7 @@ export function renderWhereChip(params: { sub: nodeSuffixes[index], facts: params.state.deviceFacts.get(node.nodeId), checked: params.execNode === node.nodeId, + disabled: !node.connected, title: nodeTooltip(node), onSelect: () => params.onSelectExecNode(node.nodeId), },