mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
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:
committed by
GitHub
parent
faa6202412
commit
2dcd47d4f4
@@ -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
@@ -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()),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export type PairedDeviceNodeSurface = {
|
|||||||
createdAtMs: number;
|
createdAtMs: number;
|
||||||
approvedAtMs: number;
|
approvedAtMs: number;
|
||||||
lastConnectedAtMs?: number;
|
lastConnectedAtMs?: number;
|
||||||
|
lastDisconnectedAtMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user