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