feat(runners): remember offline devices (#123198)

* feat(runners): remember offline devices

Persist exact node connection end timestamps and keep execution-capable offline devices visible with quiet lifecycle history in the session picker.

* test(ui): keep offline devices disabled
This commit is contained in:
Peter Steinberger 2026-08-13 13:49:00 -07:00 committed by GitHub
parent faa6202412
commit 2dcd47d4f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 856 additions and 74 deletions

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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()),

View file

@ -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;

View file

@ -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);
});

View file

@ -42,6 +42,7 @@ type KnownNodeApprovedSource = {
permissions?: Record<string, boolean>;
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>): 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,

View file

@ -58,28 +58,29 @@ function mockContext(
environmentId: string,
onCleanupError?: (error: unknown) => void,
) => Promise<TestWorkerRecord> = 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<TestWorkerRecord>;
connectedNodes?: unknown[];
} = {},
) {
const respond = vi.fn();
await environmentsHandlers[method]?.({
params: params as Record<string, unknown>,
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<Record<string, unknown>> }).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"],
});

View file

@ -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 } : {}),

View file

@ -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<unknown>();
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();
});

View file

@ -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;
}

View file

@ -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();

View file

@ -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);
}
})();
}
};

View file

@ -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);

View file

@ -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<RecordPairedNodeDisconnectionResult> {
return await withPairedDeviceRecords<RecordPairedNodeDisconnectionResult>(params.baseDir, () => {
const value = updatePairedDeviceNodeSurfaceInTransaction<RecordPairedNodeDisconnectionResult>(
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,

View file

@ -85,6 +85,7 @@ export type PairedDeviceNodeSurface = {
createdAtMs: number;
approvedAtMs: number;
lastConnectedAtMs?: number;
lastDisconnectedAtMs?: number;
};
/**

View file

@ -30,6 +30,8 @@ export type NodeListNode = {
paired?: boolean;
connected?: boolean;
connectedAtMs?: number;
lastConnectedAtMs?: number;
lastDisconnectedAtMs?: number;
lastActiveAtMs?: number;
presenceUpdatedAtMs?: number;
active?: boolean;

View file

@ -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();

View file

@ -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: [],

View file

@ -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();

View file

@ -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",

View file

@ -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"],
},

View file

@ -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 } : {}),
},

View file

@ -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 ||

View file

@ -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,

View file

@ -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

View file

@ -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],

View file

@ -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<HTMLButtonElement>(`[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();
}
});
});

View file

@ -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),
},