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
29 changed files with 856 additions and 74 deletions
@@ -1934,6 +1934,10 @@ public struct EnvironmentSummary: Codable, Sendable {
public let status: EnvironmentStatus public let status: EnvironmentStatus
public let platform: String? public let platform: String?
public let sessionhost: Bool? 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 trust: String?
public let capabilities: [String]? public let capabilities: [String]?
public let desktop: Bool? public let desktop: Bool?
@@ -1946,6 +1950,10 @@ public struct EnvironmentSummary: Codable, Sendable {
status: EnvironmentStatus, status: EnvironmentStatus,
platform: String? = nil, platform: String? = nil,
sessionhost: Bool? = nil, sessionhost: Bool? = nil,
lastconnectedatms: Int? = nil,
lastdisconnectedatms: Int? = nil,
lastseenatms: Int? = nil,
lastseenreason: String? = nil,
trust: String? = nil, trust: String? = nil,
capabilities: [String]? = nil, capabilities: [String]? = nil,
desktop: Bool? = nil, desktop: Bool? = nil,
@@ -1957,6 +1965,10 @@ public struct EnvironmentSummary: Codable, Sendable {
self.status = status self.status = status
self.platform = platform self.platform = platform
self.sessionhost = sessionhost self.sessionhost = sessionhost
self.lastconnectedatms = lastconnectedatms
self.lastdisconnectedatms = lastdisconnectedatms
self.lastseenatms = lastseenatms
self.lastseenreason = lastseenreason
self.trust = trust self.trust = trust
self.capabilities = capabilities self.capabilities = capabilities
self.desktop = desktop self.desktop = desktop
@@ -1970,6 +1982,10 @@ public struct EnvironmentSummary: Codable, Sendable {
case status case status
case platform case platform
case sessionhost = "sessionHost" case sessionhost = "sessionHost"
case lastconnectedatms = "lastConnectedAtMs"
case lastdisconnectedatms = "lastDisconnectedAtMs"
case lastseenatms = "lastSeenAtMs"
case lastseenreason = "lastSeenReason"
case trust case trust
case capabilities case capabilities
case desktop case desktop
@@ -2002,6 +2018,10 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
public let status: EnvironmentStatus public let status: EnvironmentStatus
public let platform: String? public let platform: String?
public let sessionhost: Bool? 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 trust: String?
public let capabilities: [String]? public let capabilities: [String]?
public let desktop: Bool? public let desktop: Bool?
@@ -2014,6 +2034,10 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
status: EnvironmentStatus, status: EnvironmentStatus,
platform: String? = nil, platform: String? = nil,
sessionhost: Bool? = nil, sessionhost: Bool? = nil,
lastconnectedatms: Int? = nil,
lastdisconnectedatms: Int? = nil,
lastseenatms: Int? = nil,
lastseenreason: String? = nil,
trust: String? = nil, trust: String? = nil,
capabilities: [String]? = nil, capabilities: [String]? = nil,
desktop: Bool? = nil, desktop: Bool? = nil,
@@ -2025,6 +2049,10 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
self.status = status self.status = status
self.platform = platform self.platform = platform
self.sessionhost = sessionhost self.sessionhost = sessionhost
self.lastconnectedatms = lastconnectedatms
self.lastdisconnectedatms = lastdisconnectedatms
self.lastseenatms = lastseenatms
self.lastseenreason = lastseenreason
self.trust = trust self.trust = trust
self.capabilities = capabilities self.capabilities = capabilities
self.desktop = desktop self.desktop = desktop
@@ -2038,6 +2066,10 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
case status case status
case platform case platform
case sessionhost = "sessionHost" case sessionhost = "sessionHost"
case lastconnectedatms = "lastConnectedAtMs"
case lastdisconnectedatms = "lastDisconnectedAtMs"
case lastseenatms = "lastSeenAtMs"
case lastseenreason = "lastSeenReason"
case trust case trust
case capabilities case capabilities
case desktop case desktop
@@ -2070,6 +2102,10 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
public let status: EnvironmentStatus public let status: EnvironmentStatus
public let platform: String? public let platform: String?
public let sessionhost: Bool? 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 trust: String?
public let capabilities: [String]? public let capabilities: [String]?
public let desktop: Bool? public let desktop: Bool?
@@ -2082,6 +2118,10 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
status: EnvironmentStatus, status: EnvironmentStatus,
platform: String? = nil, platform: String? = nil,
sessionhost: Bool? = nil, sessionhost: Bool? = nil,
lastconnectedatms: Int? = nil,
lastdisconnectedatms: Int? = nil,
lastseenatms: Int? = nil,
lastseenreason: String? = nil,
trust: String? = nil, trust: String? = nil,
capabilities: [String]? = nil, capabilities: [String]? = nil,
desktop: Bool? = nil, desktop: Bool? = nil,
@@ -2093,6 +2133,10 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
self.status = status self.status = status
self.platform = platform self.platform = platform
self.sessionhost = sessionhost self.sessionhost = sessionhost
self.lastconnectedatms = lastconnectedatms
self.lastdisconnectedatms = lastdisconnectedatms
self.lastseenatms = lastseenatms
self.lastseenreason = lastseenreason
self.trust = trust self.trust = trust
self.capabilities = capabilities self.capabilities = capabilities
self.desktop = desktop self.desktop = desktop
@@ -2106,6 +2150,10 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
case status case status
case platform case platform
case sessionhost = "sessionHost" case sessionhost = "sessionHost"
case lastconnectedatms = "lastConnectedAtMs"
case lastdisconnectedatms = "lastDisconnectedAtMs"
case lastseenatms = "lastSeenAtMs"
case lastseenreason = "lastSeenReason"
case trust case trust
case capabilities case capabilities
case desktop case desktop
@@ -2154,6 +2202,10 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
public let status: EnvironmentStatus public let status: EnvironmentStatus
public let platform: String? public let platform: String?
public let sessionhost: Bool? 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 trust: String?
public let capabilities: [String]? public let capabilities: [String]?
public let desktop: Bool? public let desktop: Bool?
@@ -2166,6 +2218,10 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
status: EnvironmentStatus, status: EnvironmentStatus,
platform: String? = nil, platform: String? = nil,
sessionhost: Bool? = nil, sessionhost: Bool? = nil,
lastconnectedatms: Int? = nil,
lastdisconnectedatms: Int? = nil,
lastseenatms: Int? = nil,
lastseenreason: String? = nil,
trust: String? = nil, trust: String? = nil,
capabilities: [String]? = nil, capabilities: [String]? = nil,
desktop: Bool? = nil, desktop: Bool? = nil,
@@ -2177,6 +2233,10 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
self.status = status self.status = status
self.platform = platform self.platform = platform
self.sessionhost = sessionhost self.sessionhost = sessionhost
self.lastconnectedatms = lastconnectedatms
self.lastdisconnectedatms = lastdisconnectedatms
self.lastseenatms = lastseenatms
self.lastseenreason = lastseenreason
self.trust = trust self.trust = trust
self.capabilities = capabilities self.capabilities = capabilities
self.desktop = desktop self.desktop = desktop
@@ -2190,6 +2250,10 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
case status case status
case platform case platform
case sessionhost = "sessionHost" case sessionhost = "sessionHost"
case lastconnectedatms = "lastConnectedAtMs"
case lastdisconnectedatms = "lastDisconnectedAtMs"
case lastseenatms = "lastSeenAtMs"
case lastseenreason = "lastSeenReason"
case trust case trust
case capabilities case capabilities
case desktop case desktop
+15 -9
View File
@@ -313,22 +313,28 @@ Revision 1's design rule stands: normal state is silent; only exceptions
speak. Additions: speak. Additions:
- **Use the existing environment type discriminant** for picker grouping: - **Use the existing environment type discriminant** for picker grouping:
local gateway, connected execution-capable nodes, worker environments, and local gateway, execution-capable nodes, worker environments, and the
the separate cloud profiles list. `sessionHost` is deferred to milestone 6, separate cloud profiles list. Device-runner inventory adds `sessionHost`
where device runners introduce the capability fact that needs it. without creating another place ontology.
- **Where picker regrouped** (`ui/src/pages/new-session/place-picker.ts`): - **Where picker regrouped** (`ui/src/pages/new-session/place-picker.ts`):
sections "This gateway" / "Devices" / "Cloud". Device rows intersect the 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 profiles remain their separate list. Folder and destination stay
orthogonal. 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; - **Placement chip** on the session header: shows quiet current placement;
active cloud placements reclaim through `sessions.reclaim` with "Bring active cloud placements reclaim through `sessions.reclaim` with "Bring
home". Stop-and-continue moves arrive with milestone 8. home". Stop-and-continue moves arrive with milestone 8.
- **Remaining milestone work**: live presence and pairing subscriptions, the - **Remaining milestone work**: the admin-gated "Connect a machine…" foot and
admin-gated "Connect a machine…" foot, busy and never-connected states, busy/slot state. `runner-offline` then shows a banner with the recorded
and additive `EnvironmentSummary` platform, session-host, trust, and runner reason and its recovery verbs.
version facts. `runner-offline` then shows a banner with the recorded reason
and its recovery verbs.
### Cloud convergence (milestone 10) ### Cloud convergence (milestone 10)
@@ -114,6 +114,23 @@ describe("worker environment protocol schemas", () => {
).toBe(true); ).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", () => { it("keeps desktop app launch requests, results, and projected ids closed", () => {
expect(validateWorkerDesktopLaunchParams({ environmentId: "worker:one", app: "browser" })).toBe( expect(validateWorkerDesktopLaunchParams({ environmentId: "worker:one", app: "browser" })).toBe(
true, true,
@@ -71,6 +71,10 @@ function createEnvironmentSummarySchema() {
status: EnvironmentStatusSchema, status: EnvironmentStatusSchema,
platform: Type.Optional(NonEmptyString), platform: Type.Optional(NonEmptyString),
sessionHost: Type.Optional(Type.Boolean()), 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), trust: Type.Optional(EnvironmentTrustSchema),
capabilities: Type.Optional(Type.Array(NonEmptyString)), capabilities: Type.Optional(Type.Array(NonEmptyString)),
desktop: Type.Optional(Type.Boolean()), desktop: Type.Optional(Type.Boolean()),
+4
View File
@@ -81,6 +81,10 @@ export type EnvironmentSummary = {
status: "available" | "unavailable" | "starting" | "stopping" | "error"; status: "available" | "unavailable" | "starting" | "stopping" | "error";
platform?: string; platform?: string;
sessionHost?: boolean; sessionHost?: boolean;
lastConnectedAtMs?: number;
lastDisconnectedAtMs?: number;
lastSeenAtMs?: number;
lastSeenReason?: string;
trust?: "persistent" | "disposable"; trust?: "persistent" | "disposable";
capabilities?: string[]; capabilities?: string[];
worker?: WorkerEnvironmentMetadata; worker?: WorkerEnvironmentMetadata;
+8
View File
@@ -262,6 +262,7 @@ describe("gateway/node-catalog", () => {
paired: true, paired: true,
connected: false, connected: false,
}); });
expect(getKnownNode(catalog, "mac-1")?.lastConnectedAtMs).toBeUndefined();
}); });
it("uses the newest durable last-seen source for offline nodes", () => { it("uses the newest durable last-seen source for offline nodes", () => {
@@ -282,6 +283,7 @@ describe("gateway/node-catalog", () => {
caps: [], caps: [],
commands: [], commands: [],
lastConnectedAtMs: 200, lastConnectedAtMs: 200,
lastDisconnectedAtMs: 250,
lastSeenAtMs: 100, lastSeenAtMs: 100,
lastSeenReason: "bg_app_refresh", lastSeenReason: "bg_app_refresh",
approvedAtMs: 11, approvedAtMs: 11,
@@ -291,6 +293,8 @@ describe("gateway/node-catalog", () => {
}); });
const node = getKnownNode(catalog, "ios-1"); const node = getKnownNode(catalog, "ios-1");
expect(node?.lastConnectedAtMs).toBe(200);
expect(node?.lastDisconnectedAtMs).toBe(250);
expect(node?.lastSeenAtMs).toBe(300); expect(node?.lastSeenAtMs).toBe(300);
expect(node?.lastSeenReason).toBe("silent_push"); expect(node?.lastSeenReason).toBe("silent_push");
}); });
@@ -302,6 +306,8 @@ describe("gateway/node-catalog", () => {
pairedNode({ pairedNode({
caps: ["system"], caps: ["system"],
approvedAtMs: 123, approvedAtMs: 123,
lastConnectedAtMs: 0,
lastDisconnectedAtMs: 500,
}), }),
], ],
connectedNodes: [ connectedNodes: [
@@ -326,6 +332,8 @@ describe("gateway/node-catalog", () => {
const node = getKnownNode(catalog, "mac-1"); const node = getKnownNode(catalog, "mac-1");
expect(node?.caps).toEqual(["canvas"]); expect(node?.caps).toEqual(["canvas"]);
expect(node?.commands).toEqual(["canvas.snapshot"]); expect(node?.commands).toEqual(["canvas.snapshot"]);
expect(node?.lastConnectedAtMs).toBe(1);
expect(node?.lastDisconnectedAtMs).toBeUndefined();
expect(node?.connected).toBe(true); expect(node?.connected).toBe(true);
}); });
+14
View File
@@ -42,6 +42,7 @@ type KnownNodeApprovedSource = {
permissions?: Record<string, boolean>; permissions?: Record<string, boolean>;
approvedAtMs?: number; approvedAtMs?: number;
lastConnectedAtMs?: number; lastConnectedAtMs?: number;
lastDisconnectedAtMs?: number;
lastSeenAtMs?: number; lastSeenAtMs?: number;
lastSeenReason?: string; lastSeenReason?: string;
}; };
@@ -133,6 +134,7 @@ function buildApprovedNodeSource(entry: PairedDeviceNode): KnownNodeApprovedSour
permissions: entry.permissions, permissions: entry.permissions,
approvedAtMs: entry.approvedAtMs, approvedAtMs: entry.approvedAtMs,
lastConnectedAtMs: entry.lastConnectedAtMs, lastConnectedAtMs: entry.lastConnectedAtMs,
lastDisconnectedAtMs: entry.lastDisconnectedAtMs,
lastSeenAtMs: entry.lastSeenAtMs, lastSeenAtMs: entry.lastSeenAtMs,
lastSeenReason: entry.lastSeenReason, lastSeenReason: entry.lastSeenReason,
}; };
@@ -178,6 +180,11 @@ function resolveCurrentPendingNodePairing(params: {
: undefined; : 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: { function resolveEffectiveLastSeen(params: {
live?: NodeSession; live?: NodeSession;
devicePairing?: KnownNodeDevicePairingSource; devicePairing?: KnownNodeDevicePairingSource;
@@ -222,6 +229,11 @@ function buildEffectiveKnownNode(entry: {
}): NodeListNode { }): NodeListNode {
const { nodeId, devicePairing, nodePairing, pendingNodePairing, live, sessionHost } = entry; const { nodeId, devicePairing, nodePairing, pendingNodePairing, live, sessionHost } = entry;
const lastSeen = resolveEffectiveLastSeen({ live, devicePairing, nodePairing }); const lastSeen = resolveEffectiveLastSeen({ live, devicePairing, nodePairing });
const lastConnectedAtMs = maxDefinedTimestamp(
nodePairing?.lastConnectedAtMs,
live?.connectedAtMs,
);
const lastDisconnectedAtMs = live ? undefined : nodePairing?.lastDisconnectedAtMs;
return { return {
nodeId, nodeId,
displayName: firstNormalizedString( displayName: firstNormalizedString(
@@ -299,6 +311,8 @@ function buildEffectiveKnownNode(entry: {
pendingDeclaredCommands: pendingNodePairing?.commands, pendingDeclaredCommands: pendingNodePairing?.commands,
pendingDeclaredPermissions: pendingNodePairing?.permissions, pendingDeclaredPermissions: pendingNodePairing?.permissions,
connectedAtMs: live?.connectedAtMs, connectedAtMs: live?.connectedAtMs,
lastConnectedAtMs,
lastDisconnectedAtMs,
lastActiveAtMs: live?.lastActiveAtMs, lastActiveAtMs: live?.lastActiveAtMs,
presenceUpdatedAtMs: live?.presenceUpdatedAtMs, presenceUpdatedAtMs: live?.presenceUpdatedAtMs,
lastSeenAtMs: lastSeen.lastSeenAtMs, lastSeenAtMs: lastSeen.lastSeenAtMs,
+77 -17
View File
@@ -58,28 +58,29 @@ function mockContext(
environmentId: string, environmentId: string,
onCleanupError?: (error: unknown) => void, onCleanupError?: (error: unknown) => void,
) => Promise<TestWorkerRecord> = vi.fn(async () => workerRecord({ state: "destroyed" })), ) => 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 { return {
logGateway: { logGateway: {
warn: vi.fn(), warn: vi.fn(),
}, },
nodeRegistry: { nodeRegistry: {
listConnectedForPairingStates: () => [ listConnectedForPairingStates: () => connectedNodes,
{
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,
},
],
}, },
workerEnvironmentService, workerEnvironmentService,
getRuntimeConfig: () => ({ getRuntimeConfig: () => ({
@@ -167,13 +168,19 @@ async function callEnvironmentMethod(
environmentId: string, environmentId: string,
onCleanupError?: (error: unknown) => void, onCleanupError?: (error: unknown) => void,
) => Promise<TestWorkerRecord>; ) => Promise<TestWorkerRecord>;
connectedNodes?: unknown[];
} = {}, } = {},
) { ) {
const respond = vi.fn(); const respond = vi.fn();
await environmentsHandlers[method]?.({ await environmentsHandlers[method]?.({
params: params as Record<string, unknown>, params: params as Record<string, unknown>,
respond, respond,
context: mockContext(options.service, options.reconcileActive, options.forceDestroyEnvironment), context: mockContext(
options.service,
options.reconcileActive,
options.forceDestroyEnvironment,
options.connectedNodes,
),
} as never); } as never);
const call = respond.mock.calls.at(0); const call = respond.mock.calls.at(0);
if (call === undefined) { if (call === undefined) {
@@ -234,6 +241,9 @@ describe("environment gateway methods", () => {
status: "available", status: "available",
platform: "ios", platform: "ios",
sessionHost: true, sessionHost: true,
lastConnectedAtMs: 123,
lastSeenAtMs: 123,
lastSeenReason: "connect",
trust: "persistent", trust: "persistent",
capabilities: ["camera", "system.run"], capabilities: ["camera", "system.run"],
}, },
@@ -262,6 +272,53 @@ describe("environment gateway methods", () => {
expect(environments.find((entry) => entry.id === "node:node-offline")?.sessionHost).toBe(false); 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 () => { it("marks only connected, advertised, and explicitly allowed nodes as desktop sources", async () => {
const context = mockContext(); const context = mockContext();
context.getRuntimeConfig = () => context.getRuntimeConfig = () =>
@@ -420,6 +477,9 @@ describe("environment gateway methods", () => {
status: "available", status: "available",
platform: "ios", platform: "ios",
sessionHost: true, sessionHost: true,
lastConnectedAtMs: 123,
lastSeenAtMs: 123,
lastSeenReason: "connect",
trust: "persistent", trust: "persistent",
capabilities: ["camera", "system.run"], capabilities: ["camera", "system.run"],
}); });
@@ -88,6 +88,12 @@ function summarizeNodeEnvironment(
status: node.connected ? "available" : "unavailable", status: node.connected ? "available" : "unavailable",
...(platform ? { platform } : {}), ...(platform ? { platform } : {}),
sessionHost: node.connected === true && node.sessionHost === true, 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", trust: "persistent",
...(desktop ? { desktop: true } : {}), ...(desktop ? { desktop: true } : {}),
...(capabilities.length > 0 ? { capabilities } : {}), ...(capabilities.length > 0 ? { capabilities } : {}),
+70 -10
View File
@@ -16,6 +16,7 @@ const {
attachWorkerWsMessageHandlerMock, attachWorkerWsMessageHandlerMock,
broadcastPresenceSnapshotMock, broadcastPresenceSnapshotMock,
cleanupTalkConnectionMock, cleanupTalkConnectionMock,
recordPairedNodeDisconnectionMock,
touchPresenceMock, touchPresenceMock,
upsertPresenceMock, upsertPresenceMock,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
@@ -23,6 +24,7 @@ const {
attachWorkerWsMessageHandlerMock: vi.fn((_params: unknown) => vi.fn()), attachWorkerWsMessageHandlerMock: vi.fn((_params: unknown) => vi.fn()),
broadcastPresenceSnapshotMock: vi.fn(), broadcastPresenceSnapshotMock: vi.fn(),
cleanupTalkConnectionMock: vi.fn(), cleanupTalkConnectionMock: vi.fn(),
recordPairedNodeDisconnectionMock: vi.fn(async () => ({ recorded: true })),
touchPresenceMock: vi.fn(), touchPresenceMock: vi.fn(),
upsertPresenceMock: vi.fn(), upsertPresenceMock: vi.fn(),
})); }));
@@ -33,6 +35,9 @@ vi.mock("./ws-connection/message-handler.js", () => ({
vi.mock("./ws-connection/worker-connection.js", () => ({ vi.mock("./ws-connection/worker-connection.js", () => ({
attachWorkerWsMessageHandler: attachWorkerWsMessageHandlerMock, attachWorkerWsMessageHandler: attachWorkerWsMessageHandlerMock,
})); }));
vi.mock("../../infra/device-pairing-node.js", () => ({
recordPairedNodeDisconnection: recordPairedNodeDisconnectionMock,
}));
vi.mock("../../infra/system-presence.js", () => ({ vi.mock("../../infra/system-presence.js", () => ({
touchPresence: touchPresenceMock, touchPresence: touchPresenceMock,
upsertPresence: upsertPresenceMock, upsertPresence: upsertPresenceMock,
@@ -99,6 +104,8 @@ describe("attachGatewayWsConnectionHandler", () => {
attachWorkerWsMessageHandlerMock.mockClear(); attachWorkerWsMessageHandlerMock.mockClear();
broadcastPresenceSnapshotMock.mockReset(); broadcastPresenceSnapshotMock.mockReset();
cleanupTalkConnectionMock.mockReset(); cleanupTalkConnectionMock.mockReset();
recordPairedNodeDisconnectionMock.mockReset();
recordPairedNodeDisconnectionMock.mockResolvedValue({ recorded: true });
touchPresenceMock.mockReset(); touchPresenceMock.mockReset();
upsertPresenceMock.mockReset(); upsertPresenceMock.mockReset();
}); });
@@ -362,6 +369,7 @@ describe("attachGatewayWsConnectionHandler", () => {
it("terminates a connection after one missed protocol pong", async () => { it("terminates a connection after one missed protocol pong", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const unregister = vi.fn(); const unregister = vi.fn();
const get = vi.fn(() => undefined);
const clients = new Set<unknown>(); const clients = new Set<unknown>();
const socket = Object.assign(createGatewayWsTestSocket({ ping: true }), { const socket = Object.assign(createGatewayWsTestSocket({ ping: true }), {
terminate: vi.fn(), terminate: vi.fn(),
@@ -374,7 +382,9 @@ describe("attachGatewayWsConnectionHandler", () => {
socket, socket,
options: { options: {
buildRequestContext: () => buildRequestContext: () =>
createGatewayWsTestRequestContext({ nodeRegistry: { unregister } }) as never, createGatewayWsTestRequestContext({
nodeRegistry: { get, unregister } as never,
}) as never,
}, },
}); });
const handlerParams = passed as { const handlerParams = passed as {
@@ -560,23 +570,72 @@ describe("attachGatewayWsConnectionHandler", () => {
); );
}); });
it("skips node presence disconnects for stale reconnected sockets", async () => { it("records disconnect history for the current node connection", async () => {
const unregister = vi.fn(() => null); const unregister = vi.fn(() => "node-1");
const { socket } = attachGatewayWsForTest({ const get = vi.fn();
attach: attachGatewayWsConnectionHandler, const { socket, passed } = await connectTestWs({
options: { options: {
refreshHealthSnapshot: vi.fn(), refreshHealthSnapshot: vi.fn(),
buildRequestContext: () => 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; setClient: (client: unknown) => boolean;
}; };
expect( expect(
passed.setClient({ handler.setClient({
socket, socket,
connect: { connect: {
role: "node", role: "node",
@@ -591,7 +650,8 @@ describe("attachGatewayWsConnectionHandler", () => {
socket.emit("close", 1000, Buffer.from("stale")); 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(upsertPresenceMock).not.toHaveBeenCalled();
expect(broadcastPresenceSnapshotMock).not.toHaveBeenCalled(); expect(broadcastPresenceSnapshotMock).not.toHaveBeenCalled();
}); });
+39
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 { 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 { GATEWAY_STARTUP_PENDING_CLOSE_CAUSE } from "../../../packages/gateway-protocol/src/startup-unavailable.js";
import { getRuntimeConfig } from "../../config/io.js"; import { getRuntimeConfig } from "../../config/io.js";
import { recordPairedNodeDisconnection } from "../../infra/device-pairing-node.js";
import { touchPresence, upsertPresence } from "../../infra/system-presence.js"; import { touchPresence, upsertPresence } from "../../infra/system-presence.js";
import { logRejectedLargePayload } from "../../logging/diagnostic-payload.js"; import { logRejectedLargePayload } from "../../logging/diagnostic-payload.js";
import type { createSubsystemLogger } from "../../logging/subsystem.js"; import type { createSubsystemLogger } from "../../logging/subsystem.js";
@@ -493,7 +494,25 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
// terminal.attach until their reaper fires. // terminal.attach until their reaper fires.
context.terminalSessions?.handleDisconnect(connId); context.terminalSessions?.handleDisconnect(connId);
let currentDisconnectedNodeId: string | null = null; let currentDisconnectedNodeId: string | null = null;
let disconnectedNodeHistory:
| {
nodeId: string;
connectedAtMs: number;
disconnectedAtMs: number;
pairingGeneration: string;
}
| undefined;
if (client?.connect?.role === "node") { 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 // Retire I/O immediately, but keep the client revocable until admitted
// lifecycle work drains; pairing/token removal must still fence it. // lifecycle work drains; pairing/token removal must still fence it.
retainClientUntilNodeDrain = true; retainClientUntilNodeDrain = true;
@@ -508,6 +527,26 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
} }
} }
currentDisconnectedNodeId = context.nodeRegistry.unregister(connId); 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 { } finally {
retainClientUntilNodeDrain = false; retainClientUntilNodeDrain = false;
} }
+38 -16
View File
@@ -393,8 +393,16 @@ describe("watch node HTTP transport", () => {
}); });
it("requires an authenticated disconnect and emits one lifecycle teardown", async () => { it("requires an authenticated disconnect and emits one lifecycle teardown", async () => {
const { identity, issued, nodeRegistry, connectedNodes, disconnectedNodes, runtime, baseUrl } = const {
await createWatchNodeFixture("openclaw-watch-node-disconnect-"); baseDir,
identity,
issued,
nodeRegistry,
connectedNodes,
disconnectedNodes,
runtime,
baseUrl,
} = await createWatchNodeFixture("openclaw-watch-node-disconnect-");
const connectResponse = await connectWatchNode({ const connectResponse = await connectWatchNode({
baseUrl, baseUrl,
@@ -424,9 +432,17 @@ describe("watch node HTTP transport", () => {
expect(disconnectResponse.status).toBe(200); expect(disconnectResponse.status).toBe(200);
await expect(readJson(disconnectResponse)).resolves.toEqual({ ok: true }); await expect(readJson(disconnectResponse)).resolves.toEqual({ ok: true });
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined(); expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
expect(disconnectedNodes).toEqual([ await vi.waitFor(() =>
{ nodeId: identity.deviceId, reason: "watch disconnected" }, 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`, { const repeatedDisconnect = await fetch(`${baseUrl}/disconnect`, {
method: "POST", method: "POST",
@@ -499,9 +515,11 @@ describe("watch node HTTP transport", () => {
: nodeRegistry.sendEvent(identity.deviceId, "node.invoke.request", payload); : nodeRegistry.sendEvent(identity.deviceId, "node.invoke.request", payload);
expect(delivered).toBe(false); expect(delivered).toBe(false);
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined(); expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
expect(disconnectedNodes).toEqual([ await vi.waitFor(() =>
{ nodeId: identity.deviceId, reason: "event delivery failed" }, expect(disconnectedNodes).toEqual([
]); { nodeId: identity.deviceId, reason: "event delivery failed" },
]),
);
await expect(pollFailure).resolves.toBe("ECONNRESET"); await expect(pollFailure).resolves.toBe("ECONNRESET");
expect(nodeRegistry.sendEvent(identity.deviceId, "node.invoke.request", payload)).toBe( expect(nodeRegistry.sendEvent(identity.deviceId, "node.invoke.request", payload)).toBe(
false, false,
@@ -816,10 +834,12 @@ describe("watch node HTTP transport", () => {
}); });
runtime.disconnectSessionsForDevice(identity.deviceId, { role: "node" }); runtime.disconnectSessionsForDevice(identity.deviceId, { role: "node" });
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined(); expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
expect(disconnectedNodes).toContainEqual({ await vi.waitFor(() =>
nodeId: identity.deviceId, expect(disconnectedNodes).toContainEqual({
reason: "device-token-revoked", nodeId: identity.deviceId,
}); reason: "device-token-revoked",
}),
);
const invalidatedPollResponse = await fetch(`${baseUrl}/poll`, { const invalidatedPollResponse = await fetch(`${baseUrl}/poll`, {
method: "POST", method: "POST",
headers: { authorization: `Bearer ${String(reconnected.sessionToken)}` }, headers: { authorization: `Bearer ${String(reconnected.sessionToken)}` },
@@ -875,10 +895,12 @@ describe("watch node HTTP transport", () => {
nodeRegistry.sendEventRaw(identity.deviceId, "node.invoke.request", oversizedPayload), nodeRegistry.sendEventRaw(identity.deviceId, "node.invoke.request", oversizedPayload),
).toBe(false); ).toBe(false);
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined(); expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
expect(disconnectedNodes).toContainEqual({ await vi.waitFor(() =>
nodeId: identity.deviceId, expect(disconnectedNodes).toContainEqual({
reason: "event payload too large", nodeId: identity.deviceId,
}); reason: "event payload too large",
}),
);
runtime.close(); runtime.close();
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined(); expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
+33 -5
View File
@@ -33,6 +33,7 @@ import {
releaseNodePairingCleanupClaim, releaseNodePairingCleanupClaim,
requestNodePairing, requestNodePairing,
recordPairedNodeConnection, recordPairedNodeConnection,
recordPairedNodeDisconnection,
type RequestNodePairingResult, type RequestNodePairingResult,
} from "../infra/device-pairing-node.js"; } from "../infra/device-pairing-node.js";
import { import {
@@ -320,13 +321,40 @@ export function createWatchNodeHttpRuntime(options: WatchNodeHttpRuntimeOptions)
} }
session.waiter = undefined; 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); const disconnectedNodeId = options.nodeRegistry.unregister(session.connId);
if (disconnectedNodeId) { if (disconnectedNodeId) {
try { void (async () => {
options.onNodeDisconnected?.(disconnectedNodeId, reason); try {
} catch (error) { if (disconnectHistory && disconnectHistory.nodeId === disconnectedNodeId) {
options.onError?.("watch node disconnect cleanup failed", error); 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);
}
})();
} }
}; };
+76
View File
@@ -9,6 +9,7 @@ import {
finalizeNodePairingCleanupClaim, finalizeNodePairingCleanupClaim,
listNodePairing, listNodePairing,
recordPairedNodeConnection, recordPairedNodeConnection,
recordPairedNodeDisconnection,
releaseNodePairingCleanupClaim, releaseNodePairingCleanupClaim,
renamePairedNode, renamePairedNode,
requestNodePairing, 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 () => { test("serializes connection metadata with locked node-surface mutations", async () => {
await withNodePairingDir(async (baseDir) => { await withNodePairingDir(async (baseDir) => {
await setupPairedNode(baseDir); await setupPairedNode(baseDir);
+52 -1
View File
@@ -79,6 +79,7 @@ export type PairedDeviceNode = NodeDeclaredSurface & {
createdAtMs: number; createdAtMs: number;
approvedAtMs: number; approvedAtMs: number;
lastConnectedAtMs?: number; lastConnectedAtMs?: number;
lastDisconnectedAtMs?: number;
lastSeenAtMs?: number; lastSeenAtMs?: number;
lastSeenReason?: string; lastSeenReason?: string;
}; };
@@ -187,6 +188,7 @@ function toPairedNode(
createdAtMs: surface.createdAtMs, createdAtMs: surface.createdAtMs,
approvedAtMs: surface.approvedAtMs, approvedAtMs: surface.approvedAtMs,
lastConnectedAtMs: surface.lastConnectedAtMs, lastConnectedAtMs: surface.lastConnectedAtMs,
lastDisconnectedAtMs: surface.lastDisconnectedAtMs,
lastSeenAtMs: device.lastSeenAtMs, lastSeenAtMs: device.lastSeenAtMs,
lastSeenReason: device.lastSeenReason, lastSeenReason: device.lastSeenReason,
}; };
@@ -698,12 +700,17 @@ export async function recordPairedNodeConnection(
// both claim the same node's first connection and schedule duplicate alerts. // both claim the same node's first connection and schedule duplicate alerts.
const firstConnection = device.nodeSurface.lastConnectedAtMs === undefined; const firstConnection = device.nodeSurface.lastConnectedAtMs === undefined;
const previousConnectedAtMs = device.nodeSurface.lastConnectedAtMs ?? connectedAtMs; const previousConnectedAtMs = device.nodeSurface.lastConnectedAtMs ?? connectedAtMs;
const lastConnectedAtMs = Math.max(previousConnectedAtMs, connectedAtMs);
const clearsDisconnect =
device.nodeSurface.lastDisconnectedAtMs !== undefined &&
connectedAtMs > device.nodeSurface.lastDisconnectedAtMs;
return { return {
value: { recorded: true, firstConnection }, value: { recorded: true, firstConnection },
persist: true, persist: true,
nodeSurface: { nodeSurface: {
...device.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. */ /** Rename a paired node display name while preserving approval metadata. */
export async function renamePairedNode( export async function renamePairedNode(
nodeId: string, nodeId: string,
+1
View File
@@ -85,6 +85,7 @@ export type PairedDeviceNodeSurface = {
createdAtMs: number; createdAtMs: number;
approvedAtMs: number; approvedAtMs: number;
lastConnectedAtMs?: number; lastConnectedAtMs?: number;
lastDisconnectedAtMs?: number;
}; };
/** /**
+2
View File
@@ -30,6 +30,8 @@ export type NodeListNode = {
paired?: boolean; paired?: boolean;
connected?: boolean; connected?: boolean;
connectedAtMs?: number; connectedAtMs?: number;
lastConnectedAtMs?: number;
lastDisconnectedAtMs?: number;
lastActiveAtMs?: number; lastActiveAtMs?: number;
presenceUpdatedAtMs?: number; presenceUpdatedAtMs?: number;
active?: boolean; active?: boolean;
@@ -76,8 +76,10 @@ suite.define(() => {
{ {
id: "node:offline-rich", id: "node:offline-rich",
type: "node", type: "node",
status: "available", status: "unavailable",
sessionHost: true, sessionHost: false,
lastConnectedAtMs: 1_000,
lastDisconnectedAtMs: 4_000,
capabilities: ["camera", "screen"], capabilities: ["camera", "screen"],
}, },
{ {
@@ -130,7 +132,12 @@ suite.define(() => {
expect( expect(
await place.locator('[data-value="gateway"] .new-session-page__menu-fact').count(), await place.locator('[data-value="gateway"] .new-session-page__menu-fact').count(),
).toBe(0); ).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); expect(await place.locator('[data-value="node:non-exec-rich"]').count()).toBe(0);
const visibleCopy = ((await place.textContent()) ?? "").toLowerCase(); const visibleCopy = ((await place.textContent()) ?? "").toLowerCase();
@@ -39,6 +39,9 @@ suite.define(() => {
}); });
it("refreshes destinations from gateway events while the picker stays open", async () => { 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({ const context = await suite.browser.newContext({
locale: "en-US", locale: "en-US",
serviceWorkers: "block", serviceWorkers: "block",
@@ -124,11 +127,103 @@ suite.define(() => {
await expect await expect
.poll(async () => (await gateway.getRequests("environments.list")).length) .poll(async () => (await gateway.getRequests("environments.list")).length)
.toBeGreaterThan(environmentRequests); .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.getByRole("button", { name: "Local" }).waitFor();
await place.getByText("Your devices", { exact: true }).waitFor(); await place.getByText("Your devices", { exact: true }).waitFor();
expect(await place.getAttribute("open")).not.toBeNull(); 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; const refreshedEnvironmentRequests = (await gateway.getRequests("environments.list")).length;
await gateway.setMethodResponse("environments.list", { await gateway.setMethodResponse("environments.list", {
environments: [], environments: [],
@@ -742,7 +742,9 @@ suite.define(() => {
await whereSelect.getByRole("button", { name: "Local" }).click(); await whereSelect.getByRole("button", { name: "Local" }).click();
await pollLocatorText(whereLabel).toBe("Local"); await pollLocatorText(whereLabel).toBe("Local");
await whereTrigger.click(); 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 whereSelect.getByRole("button", { name: "MacBook" }).click();
await projectTrigger.click(); await projectTrigger.click();
await projectSelect.getByRole("button", { name: "Browse folders" }).click(); await projectSelect.getByRole("button", { name: "Browse folders" }).click();
+3
View File
@@ -719,6 +719,9 @@ export const en: TranslationMap = {
folder: "Folder", folder: "Folder",
folderPlaceholder: "Agent workspace", folderPlaceholder: "Agent workspace",
yourDevices: "Your devices", yourDevices: "Your devices",
neverConnected: "Never connected",
offlineFor: "Offline for {duration}",
lastSeen: "Last seen {time}",
capabilityCamera: "Camera", capabilityCamera: "Camera",
capabilityLocation: "Location", capabilityLocation: "Location",
capabilityTalk: "Talk", capabilityTalk: "Talk",
@@ -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", () => { describe("readDraftCloudProfiles", () => {
it("keeps closed profile summaries in stable order", () => { it("keeps closed profile summaries in stable order", () => {
@@ -82,6 +106,10 @@ describe("readDraftEnvironments", () => {
type: "node", type: "node",
platform: " darwin ", platform: " darwin ",
sessionHost: false, sessionHost: false,
lastConnectedAtMs: 1_000.9,
lastDisconnectedAtMs: 2_000,
lastSeenAtMs: 1_500,
lastSeenReason: " silent_push ",
trust: "persistent", trust: "persistent",
capabilities: [" camera.snap ", 42, "custom.unknown", "system.run", null], capabilities: [" camera.snap ", 42, "custom.unknown", "system.run", null],
}, },
@@ -100,6 +128,10 @@ describe("readDraftEnvironments", () => {
type: "node", type: "node",
platform: "darwin", platform: "darwin",
sessionHost: false, sessionHost: false,
lastConnectedAtMs: 1_000,
lastDisconnectedAtMs: 2_000,
lastSeenAtMs: 1_500,
lastSeenReason: "silent_push",
trust: "persistent", trust: "persistent",
capabilities: ["camera.snap", "custom.unknown", "system.run"], capabilities: ["camera.snap", "custom.unknown", "system.run"],
}, },
+24 -2
View File
@@ -39,12 +39,22 @@ export type DraftEnvironment = {
type: "local" | "node" | "worker"; type: "local" | "node" | "worker";
platform?: string; platform?: string;
sessionHost?: boolean; sessionHost?: boolean;
lastConnectedAtMs?: number;
lastDisconnectedAtMs?: number;
lastSeenAtMs?: number;
lastSeenReason?: string;
trust?: "persistent" | "disposable"; trust?: "persistent" | "disposable";
capabilities?: string[]; capabilities?: string[];
}; };
export type BrowserTarget = { nodeId: string; label: 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[] { export function readDraftNodes(value: unknown): DraftNode[] {
const rawNodes = Array.isArray(value) ? value : []; const rawNodes = Array.isArray(value) ? value : [];
return rawNodes return rawNodes
@@ -70,7 +80,7 @@ export function readDraftNodes(value: unknown): DraftNode[] {
return []; return [];
} }
const connected = node.connected === true; const connected = node.connected === true;
const canExec = connected && commands.includes("system.run"); const canExec = commands.includes("system.run");
return [ return [
{ {
nodeId, nodeId,
@@ -81,7 +91,7 @@ export function readDraftNodes(value: unknown): DraftNode[] {
remoteIp: normalizeOptionalString(node.remoteIp), remoteIp: normalizeOptionalString(node.remoteIp),
connected, connected,
canExec, 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; type?: unknown;
platform?: unknown; platform?: unknown;
sessionHost?: unknown; sessionHost?: unknown;
lastConnectedAtMs?: unknown;
lastDisconnectedAtMs?: unknown;
lastSeenAtMs?: unknown;
lastSeenReason?: unknown;
trust?: unknown; trust?: unknown;
capabilities?: unknown; capabilities?: unknown;
}; };
@@ -138,6 +152,10 @@ export function readDraftEnvironments(value: unknown): DraftEnvironment[] {
? environment.trust ? environment.trust
: undefined; : undefined;
const capabilities = normalizeArrayBackedTrimmedStringList(environment.capabilities); 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 [ return [
{ {
id, id,
@@ -146,6 +164,10 @@ export function readDraftEnvironments(value: unknown): DraftEnvironment[] {
...(typeof environment.sessionHost === "boolean" ...(typeof environment.sessionHost === "boolean"
? { sessionHost: environment.sessionHost } ? { sessionHost: environment.sessionHost }
: {}), : {}),
...(lastConnectedAtMs !== undefined ? { lastConnectedAtMs } : {}),
...(lastDisconnectedAtMs !== undefined ? { lastDisconnectedAtMs } : {}),
...(lastSeenAtMs !== undefined ? { lastSeenAtMs } : {}),
...(lastSeenReason ? { lastSeenReason } : {}),
...(trust ? { trust } : {}), ...(trust ? { trust } : {}),
...(capabilities ? { capabilities } : {}), ...(capabilities ? { capabilities } : {}),
}, },
@@ -144,10 +144,14 @@ export class DraftPlaceState {
return this.agents().find((agent) => normalizeAgentId(agent.id) === agentId); return this.agents().find((agent) => normalizeAgentId(agent.id) === agentId);
} }
execNodes(): DraftNode[] { executionNodes(): DraftNode[] {
return this.nodesValue.filter((node) => node.canExec); return this.nodesValue.filter((node) => node.canExec);
} }
execNodes(): DraftNode[] {
return this.executionNodes().filter((node) => node.connected);
}
execNodeReady(): boolean { execNodeReady(): boolean {
return ( return (
!this.execNodeValue || !this.execNodeValue ||
+2 -1
View File
@@ -359,6 +359,7 @@ class NewSessionPage extends OpenClawLightDomElement {
private renderPlaceChips() { private renderPlaceChips() {
const execNodes = this.place.execNodes(); const execNodes = this.place.execNodes();
const executionNodes = this.place.executionNodes();
const cloudProfiles = catalog.isTarget(this.data) ? [] : this.gateway.cloudProfiles; const cloudProfiles = catalog.isTarget(this.data) ? [] : this.gateway.cloudProfiles;
const branches = this.place.repository.kind === "git" ? this.place.repository : null; const branches = this.place.repository.kind === "git" ? this.place.repository : null;
const projects = catalog.isTarget(this.data) ? [] : this.browser.projects; const projects = catalog.isTarget(this.data) ? [] : this.browser.projects;
@@ -372,7 +373,7 @@ class NewSessionPage extends OpenClawLightDomElement {
isAdmin: this.place.isAdmin(), isAdmin: this.place.isAdmin(),
}); });
const whereState = resolveWhereChip({ const whereState = resolveWhereChip({
execNodes: this.place.isAdmin() ? execNodes : [], execNodes: this.place.isAdmin() ? executionNodes : [],
environments: this.place.isAdmin() ? this.gateway.environments : [], environments: this.place.isAdmin() ? this.gateway.environments : [],
cloudProfiles: this.place.isAdmin() ? cloudProfiles : [], cloudProfiles: this.place.isAdmin() ? cloudProfiles : [],
cloudProfileId: this.place.cloudProfileId, cloudProfileId: this.place.cloudProfileId,
+38 -2
View File
@@ -1,4 +1,5 @@
import { t } from "../../i18n/index.ts"; import { t } from "../../i18n/index.ts";
import { formatDurationCompact, formatRelativeTimestamp } from "../../lib/format.ts";
import { prettifyPlatform } from "../../lib/platform-label.ts"; import { prettifyPlatform } from "../../lib/platform-label.ts";
import type { DraftEnvironment } from "./discovery.ts"; import type { DraftEnvironment } from "./discovery.ts";
@@ -13,8 +14,43 @@ const CAPABILITY_FACT_KEYS = {
voice: "newSession.capabilityVoice", voice: "newSession.capabilityVoice",
} as const; } as const;
export function environmentMenuFacts(environment: DraftEnvironment | undefined): string[] { function environmentLifecycleFact(params: {
const facts = environment?.platform ? [prettifyPlatform(environment.platform)] : []; 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 ?? []) { for (const capability of environment?.capabilities ?? []) {
const family = capability.split(".", 1)[0]?.toLowerCase(); const family = capability.split(".", 1)[0]?.toLowerCase();
const key = family const key = family
@@ -15,12 +15,13 @@ export function resolvePlacePickerSections(params: {
: null; : null;
return { return {
deviceNodes: params.execNodes.filter((node) => { deviceNodes: params.execNodes.filter((node) => {
if (!node.connected || !node.canExec) { if (!node.canExec) {
return false; return false;
} }
if (environmentById === null || environmentById.size === 0) { if (environmentById === null || environmentById.size === 0) {
// Missing and empty catalogs preserve the established live-node fallback. // Missing and empty catalogs preserve the established live-node fallback;
return true; // offline rows need lifecycle facts from the environment read model.
return node.connected;
} }
const environment = environmentById.get(`node:${node.nodeId}`); const environment = environmentById.get(`node:${node.nodeId}`);
return environment?.type === "node"; return environment?.type === "node";
@@ -28,7 +29,9 @@ export function resolvePlacePickerSections(params: {
deviceFacts: new Map( deviceFacts: new Map(
params.execNodes.map((node) => [ params.execNodes.map((node) => [
node.nodeId, node.nodeId,
environmentMenuFacts(environmentById?.get(`node:${node.nodeId}`)), environmentMenuFacts(environmentById?.get(`node:${node.nodeId}`), {
connected: node.connected,
}),
]), ]),
), ),
cloudProfiles: [...params.cloudProfiles], cloudProfiles: [...params.cloudProfiles],
+115 -1
View File
@@ -1,5 +1,5 @@
import { render } from "lit"; import { render } from "lit";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { readDraftEnvironments } from "./discovery.ts"; import { readDraftEnvironments } from "./discovery.ts";
import { renderWhereChip, resolveWhereChip } from "./where-chip.ts"; import { renderWhereChip, resolveWhereChip } from "./where-chip.ts";
@@ -164,4 +164,118 @@ describe("Where chip state", () => {
expect(visibleCopy).not.toContain(clutter); 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();
}
});
}); });
+1
View File
@@ -143,6 +143,7 @@ export function renderWhereChip(params: {
sub: nodeSuffixes[index], sub: nodeSuffixes[index],
facts: params.state.deviceFacts.get(node.nodeId), facts: params.state.deviceFacts.get(node.nodeId),
checked: params.execNode === node.nodeId, checked: params.execNode === node.nodeId,
disabled: !node.connected,
title: nodeTooltip(node), title: nodeTooltip(node),
onSelect: () => params.onSelectExecNode(node.nodeId), onSelect: () => params.onSelectExecNode(node.nodeId),
}, },