From 3aaf13ca842768fdeb4fa6043391766dc76ef5ad Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 27 Aug 2026 08:07:22 -0700 Subject: [PATCH] feat(ui): show activity cards for online people (#130664) * feat(ui): show activity cards for online people Record live person timing independently of heartbeat freshness and expose keyboard- and touch-accessible cards with visible session links. Preserve continuous online intervals across overlapping tabs without adding persistent activity history. Closes #130649 * build(protocol): refresh generated presence timing fields * fix(gateway): scope presence snapshots to read-access operators Apply one recipient projection to hello, system-presence, and events. Keep person timing and time-zone details behind read access, and filter watched references with the canonical session-list policy without leaking hidden counts. Preserve idle-person metadata for readers and prevent non-reader activity-driven frames. * fix(plugins): preserve Gateway capability load context Carry the owning registry's metadata generation, workspace, install records, and built-artifact preference through capability discovery. Preserve standalone source loading and existing speech eligibility while avoiding synchronous source transforms on cold Talk catalog requests. Fixes #130777 * test: align integrated presence and capability coverage Keep the recipient matrix in its timing-aware owner, close the suite-owned Gateway for shutdown proof, and spy on the canonical install-record reader. Remove the retired private carrier's assertion allowance. * fix(gateway): broadcast presence when clients connect Publish completed connection rows through the canonical scoped broadcaster so established readers see first connections and reconnects without waiting for profile edits or activity. Preserve shared online intervals and reset them only after the final socket closes. * fix(ci): align presence fixtures and sidebar type ownership Reuse the canonical sidebar host type to remove the type-only lazy-runtime cycle. Assert omitted scopes through hello auth and denied presence reads, and supply presence version callbacks in the shared WebSocket context fixture. Retain startup admission and drain assertions without changing deadlines. * fix(ui): retain latest scroll commands until their destination Do not treat the virtualizer's idle debounce or a decreasing offset as reader takeover. Settle at the actual DOM end within one pixel, retaining the separate eight-pixel UI-follow policy and explicit input cancellation. Cover retargeted idle delivery and near-end precision with the real dependency, synchronize pointer baseline capture, and type the existing composer snapshot as its textarea contract. Fixes #130892 --- .../OpenClawProtocol/GatewayModels.swift | 8 + config/assertion-safety-baseline.txt | 3 +- docs/concepts/multi-user.md | 12 + docs/concepts/presence.md | 49 ++- .../src/schema/snapshot.test.ts | 10 + .../gateway-protocol/src/schema/snapshot.ts | 8 +- scripts/control-ui-mock-dev.ts | 37 +- src/agents/openclaw-plugin-tools.ts | 4 +- ...w-tools.browser-plugin.integration.test.ts | 8 +- ...red-model-runtime.inbound-registry.test.ts | 4 +- ...pared-model-runtime.plugin-context.test.ts | 10 +- .../prepared-model-runtime.plugin-context.ts | 21 +- src/agents/prepared-model-runtime.test.ts | 4 +- src/gateway/gateway-misc.test.ts | 26 +- src/gateway/presence-projection.ts | 20 +- .../server-broadcast.serialization.test.ts | 32 +- src/gateway/server-broadcast.ts | 3 +- src/gateway/server-core-runtime.ts | 13 +- src/gateway/server-lifecycle.ts | 15 +- .../server-methods/chat-send-handler.ts | 1 + .../chat.error-broadcast.test.ts | 104 ++++- .../server-methods/sessions-suggestions.ts | 3 + .../server-methods/sessions-typing.test.ts | 42 ++ src/gateway/server-methods/shared-types.ts | 2 + src/gateway/server-plugin-bootstrap.ts | 3 + src/gateway/server-plugins.test.ts | 12 +- src/gateway/server-plugins.ts | 99 +++-- src/gateway/server-request-context.test.ts | 100 ++++- src/gateway/server-request-context.ts | 35 +- .../server.auth.default-token.suite.ts | 26 +- .../server.auth.identity-scopes.test.ts | 190 --------- .../server.auth.presence-audience.test.ts | 380 +++++++++++++++++ src/gateway/server.health.test.ts | 32 +- src/gateway/server/client-presence.test.ts | 235 +++++++++++ src/gateway/server/client-presence.ts | 80 ++++ .../server/ws-connection.startup.test.ts | 2 - .../server/ws-connection.test-helpers.ts | 3 + src/gateway/server/ws-connection.ts | 27 +- .../server/ws-connection/connect-session.ts | 18 +- ...handler.control-ui-build-admission.test.ts | 13 +- ...essage-handler.post-connect-health.test.ts | 179 ++++---- ...ssage-handler.suspension-admission.test.ts | 7 +- src/gateway/server/ws-types.ts | 2 + src/gateway/session-viewer-presence.test.ts | 102 +++-- src/gateway/session-viewer-presence.ts | 23 +- src/infra/system-presence.ts | 4 + src/plugins/bundled-capability-runtime.ts | 55 ++- ...bility-provider-runtime.generation.test.ts | 306 ++++++++++++++ .../capability-provider-runtime.test.ts | 8 +- src/plugins/capability-provider-runtime.ts | 75 +++- src/plugins/runtime/load-context.ts | 22 + ui/src/components/app-sidebar-render.ts | 68 +-- ui/src/components/app-sidebar.ts | 7 + ui/src/components/person-activity-card.ts | 276 ++++++++++++ ui/src/components/portaled-hovercard.ts | 36 +- .../session-progress-hovercard.runtime.ts | 18 +- .../components/sidebar-people-controller.ts | 122 ++++++ ui/src/components/sidebar-people.runtime.ts | 399 ++++++++++++++++++ ...t-transcript-disclosure-anchor.e2e.test.ts | 3 + ui/src/e2e/people-activity-card.e2e.test.ts | 228 ++++++++++ ui/src/i18n/locales/en.ts | 21 + .../chat-transcript-controller.test.ts | 50 +++ .../components/chat-transcript-controller.ts | 22 +- ui/src/styles/components.css | 160 +++++++ ui/src/styles/layout.css | 60 ++- .../app-sidebar-cases/presence.ts | 252 ++++++++++- ui/src/test-helpers/app-sidebar.ts | 1 + ui/src/test-helpers/control-ui-e2e.ts | 6 + 68 files changed, 3563 insertions(+), 643 deletions(-) create mode 100644 src/gateway/server.auth.presence-audience.test.ts create mode 100644 src/gateway/server/client-presence.test.ts create mode 100644 src/gateway/server/client-presence.ts create mode 100644 src/plugins/capability-provider-runtime.generation.test.ts create mode 100644 ui/src/components/person-activity-card.ts create mode 100644 ui/src/components/sidebar-people-controller.ts create mode 100644 ui/src/components/sidebar-people.runtime.ts create mode 100644 ui/src/e2e/people-activity-card.e2e.test.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index e66dd529a623..57a0fb041c9a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1530,6 +1530,8 @@ public struct PresenceEntry: Codable, Sendable { public let tags: [String]? public let text: String? public let ts: Int + public let onlinesince: Int? + public let lastactivityat: Int? public let deviceid: String? public let roles: [String]? public let scopes: [String]? @@ -1551,6 +1553,8 @@ public struct PresenceEntry: Codable, Sendable { tags: [String]? = nil, text: String? = nil, ts: Int, + onlinesince: Int? = nil, + lastactivityat: Int? = nil, deviceid: String? = nil, roles: [String]? = nil, scopes: [String]? = nil, @@ -1571,6 +1575,8 @@ public struct PresenceEntry: Codable, Sendable { self.tags = tags self.text = text self.ts = ts + self.onlinesince = onlinesince + self.lastactivityat = lastactivityat self.deviceid = deviceid self.roles = roles self.scopes = scopes @@ -1593,6 +1599,8 @@ public struct PresenceEntry: Codable, Sendable { case tags case text case ts + case onlinesince = "onlineSince" + case lastactivityat = "lastActivityAt" case deviceid = "deviceId" case roles case scopes diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index f5c65e59ba54..8a0023442f6a 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -1960,7 +1960,6 @@ src/agents/prepared-model-catalog.ts 1 src/agents/prepared-model-catalog.worker.ts 1 src/agents/prepared-model-runtime.configured.ts 2 src/agents/prepared-model-runtime.facts.ts 1 -src/agents/prepared-model-runtime.plugin-context.ts 2 src/agents/prepared-model-runtime.ts 1 src/agents/provider-attribution.ts 14 src/agents/provider-auth-aliases.ts 2 @@ -3033,7 +3032,7 @@ src/gateway/server.auth.control-ui.mobile-bootstrap.suite.ts 7 src/gateway/server.auth.control-ui.owner-bootstrap.suite.ts 5 src/gateway/server.auth.control-ui.pairing.suite.ts 6 src/gateway/server.auth.control-ui.trusted-proxy.suite.ts 5 -src/gateway/server.auth.default-token.suite.ts 11 +src/gateway/server.auth.default-token.suite.ts 10 src/gateway/server.auth.modes.suite.ts 1 src/gateway/server/hooks-request-handler.ts 4 src/gateway/server/http-listen.ts 1 diff --git a/docs/concepts/multi-user.md b/docs/concepts/multi-user.md index 6be639fca996..03a3694b814f 100644 --- a/docs/concepts/multi-user.md +++ b/docs/concepts/multi-user.md @@ -60,6 +60,18 @@ When several people watch the same session, the transcript also shows a live typ When the loaded session list contains fewer than two distinct owner identities and no session has recorded outside participants, OpenClaw hides all ownership and owner-filter chrome. A single-user gateway therefore looks unchanged. +## People cards + +Hover or focus a person in the sidebar's **Online** section to open their information card. The details button also opens the card on touch devices. Selecting the person's name or **View activity** opens their Activity page. + +The card shows how long the person has been continuously connected, their reported app/device context and time zone, and their last observed activity during that online period. Opening a different session, typing, and sending a new message count as activity; connection heartbeats and agent responses do not. **Not observed yet** means no qualifying activity has been recorded, not that the person is inactive. These timing facts are ephemeral and reset after the person's final connection closes or the Gateway restarts. + +People presence is shared with operators who have read access (`operator.read`, also implied by `operator.write` or `operator.admin`). Those readers may see other people's online and activity timing and reported time zone whether or not the person is watching a session. Node and pairing-only connections receive neither the presence inventory nor its activity-driven events. This does not change cross-reader IP visibility or provide isolation for all Gateway metadata; see [Who can see presence](/concepts/presence#who-can-see-presence). + +**Viewing now** and **Recent sessions** link only to sessions available in your loaded session list. Recent sessions reflect reliable ownership or creation attribution, not a complete history of the person's contributions. Session update times describe the session, not when that person last acted. Connection descriptions and time zones are client-reported hints, not verified physical locations. + +The Gateway also filters watched-session references for each recipient using `sessions.list` visibility rules, across connect snapshots, presence RPC responses, and events. Hidden or missing references are omitted without counts or placeholders; opening someone's card never borrows that person's session access. + ## Agent-spawned sessions Sessions an agent creates with `sessions_spawn` (`visible: true`) are attributed to the requesting agent: the creator and initial owner is the agent itself, and the sidebar shows the agent's configured identity name and avatar rather than an internal session key. diff --git a/docs/concepts/presence.md b/docs/concepts/presence.md index 8a4538f04ee9..604cfb353f91 100644 --- a/docs/concepts/presence.md +++ b/docs/concepts/presence.md @@ -33,33 +33,38 @@ Presence entries are structured objects with fields like: - `lastInputSeconds`: seconds since last user input, if known - `reason`: free-form client-supplied string; the Gateway itself only emits `self`, `connect`, and `disconnect` - `deviceId`, `roles`, `scopes`: device identity and role/scope hints from the connect handshake -- `ts`: last update timestamp (ms since epoch) +- `ts`: last presence update timestamp (ms since epoch), including heartbeat updates; not a user-activity timestamp +- `onlineSince`: start of an authenticated person's current continuous online period, shared across overlapping connections +- `lastActivityAt`: latest observed accepted interaction during that online period; absent until activity is observed - `watchedSessions`: session keys the client explicitly declares it is viewing, filtered for the recipient -### Watched session references +## Who can see presence -Hello snapshots, `system-presence`, and `presence` events include only watched -session references that the recipient can see under the current session-list -visibility rules. Drafts, incognito sessions, and operator role restrictions -follow those same rules. Missing or deleted sessions are omitted, including for -admins. Keys retain their agent scope, including agent-qualified `global` and -`unknown` references. +The presence roster is shared with operators who have `operator.read` access; +`operator.write` and `operator.admin` also grant read access. Readers can see other +people's online and activity timing and reported `timeZone`, including people who +are not watching a session. Node connections, pairing-only operators, and other +connections without read access receive an empty presence roster in the connect +snapshot and no `presence` events. The `system-presence` RPC requires the same +operator read access. -Session references require an operator connection with read access -(`operator.read`, `operator.write`, or `operator.admin`). Nodes, pairing-only -clients, and non-admin clients awaiting authenticated profile verification still -receive the non-session presence roster in hello snapshots and events, without -watched references. An established admin grant does not depend on profile -verification. `system-presence` itself requires operator read access. +Watched-session references are filtered separately for each recipient using the +same visibility rules as `sessions.list`. Hidden or missing sessions are omitted +entirely, without counts or placeholders. This filtering applies to connect +snapshots, `system-presence` responses, and presence events; the person being +viewed does not grant the recipient access to their sessions. -Filtering preserves the connection and person metadata and the presence timestamp. -When no references are visible, `watchedSessions` is omitted, just as when the -client declares no watches; there are no hidden-session counts or markers. +Drafts, incognito sessions, and operator role restrictions follow those list +rules. Missing or deleted references are omitted even for admins. Keys retain +their agent scope, including agent-qualified `global` and `unknown` references. +Non-admin readers awaiting authenticated profile verification receive person +metadata but no watched references; established admin grants retain admin list +visibility. When no references are visible, `watchedSessions` is omitted. Message subscriptions alone do not declare viewer presence. -These are coordination features within a shared agent, not isolation between -mutually untrusted users. Everyone operating an agent shares that agent's -capabilities. See [Multi-user trust boundary](/concepts/multi-user#trust-boundary). +This policy does not change which IP addresses are shared between readers and +does not isolate all Gateway metadata. Use separate Gateway trust boundaries +when readers must not see each other's presence or other shared metadata. ## Producers (where presence comes from) @@ -111,6 +116,10 @@ by parsed host or other beacon metadata. A stable `instanceId` helps consumers associate rows with the same client; it does not merge separate user WebSocket connections. Ephemeral control-plane clients are excluded from tracking entirely. +The Control UI groups connection rows by authenticated identity when displaying +people. The [people card](/concepts/multi-user#people-cards) keeps online duration +and observed activity separate from each entry's heartbeat freshness. + ## TTL and bounded size Presence is intentionally ephemeral: diff --git a/packages/gateway-protocol/src/schema/snapshot.test.ts b/packages/gateway-protocol/src/schema/snapshot.test.ts index 31d6c5b53580..77ebcb695bb9 100644 --- a/packages/gateway-protocol/src/schema/snapshot.test.ts +++ b/packages/gateway-protocol/src/schema/snapshot.test.ts @@ -19,6 +19,8 @@ describe("SnapshotSchema", () => { SnapshotSchema, snapshotWithPresence({ ts: 1, + onlineSince: 0, + lastActivityAt: 1, user: { id: "alice@example.com", email: "alice@example.com" }, }), ), @@ -29,6 +31,14 @@ describe("SnapshotSchema", () => { expect(Value.Check(SnapshotSchema, snapshotWithPresence({ ts: 1 }))).toBe(true); }); + it.each(["onlineSince", "lastActivityAt"])("rejects non-millisecond %s values", (field) => { + for (const value of [-1, 1.5, "1000", null]) { + expect(Value.Check(SnapshotSchema, snapshotWithPresence({ ts: 1, [field]: value }))).toBe( + false, + ); + } + }); + it("accepts optional watched session keys", () => { expect( Value.Check( diff --git a/packages/gateway-protocol/src/schema/snapshot.ts b/packages/gateway-protocol/src/schema/snapshot.ts index ee0428c4bbf2..c61196ff5368 100644 --- a/packages/gateway-protocol/src/schema/snapshot.ts +++ b/packages/gateway-protocol/src/schema/snapshot.ts @@ -26,21 +26,25 @@ export const PresenceEntrySchema = closedObject({ reason: Type.Optional(NonEmptyString), tags: Type.Optional(Type.Array(NonEmptyString)), text: Type.Optional(Type.String()), + /** Heartbeat freshness, not online duration or user activity. */ ts: Type.Integer({ minimum: 0 }), + /** Server timestamps for the person's continuous online interval and last accepted activity. */ + onlineSince: Type.Optional(Type.Integer({ minimum: 0 })), + lastActivityAt: Type.Optional(Type.Integer({ minimum: 0 })), deviceId: Type.Optional(NonEmptyString), roles: Type.Optional(Type.Array(NonEmptyString)), scopes: Type.Optional(Type.Array(NonEmptyString)), instanceId: Type.Optional(NonEmptyString), user: Type.Optional( closedObject({ - /** Opaque identity key: authenticated email today, durable profile id later. Clients group presence by this. */ + /** Canonical profile id when resolved, otherwise authenticated identity. Clients group presence by this. */ id: NonEmptyString, email: Type.Optional(NonEmptyString), name: Type.Optional(NonEmptyString), avatarUrl: Type.Optional(NonEmptyString), }), ), - /** Session keys this connection is actively subscribed to (watching). Sorted lexicographically for deterministic snapshots. */ + /** Sessions this connection declares it is viewing, independent of transport subscriptions. Sorted lexicographically. */ watchedSessions: Type.Optional(Type.Array(NonEmptyString)), }); diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 6680e82389a6..69dc3d5e7da0 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -1698,7 +1698,8 @@ async function createChatPickerScenario( ] : []; const workboardMocks = buildWorkboardMocks(baseTime); - const activitySessions = buildActivitySessionRows(Date.now()); + const activityTime = Date.now(); + const activitySessions = buildActivitySessionRows(activityTime); const sessions = [ ...activitySessions, ...(fixture === "workboard" @@ -2059,8 +2060,38 @@ async function createChatPickerScenario( name: selfProfile.displayName ?? undefined, email: selfProfile.emails[0], }, - { id: "presence-colin", name: "Colin", email: "colin@example.com" }, - { id: "presence-patricia", email: "patricia.erichsen@example.com" }, + { + id: "presence-colin", + name: "Colin", + email: "colin@example.com", + onlineSince: activityTime - 47 * 60_000, + lastActivityAt: activityTime - 2 * 60_000, + deviceFamily: "Mac", + platform: "macOS", + timeZone: "America/Los_Angeles", + watchedSessions: ["agent:activity:design-review", "agent:main:main"], + }, + { + id: "presence-colin", + name: "Colin", + email: "colin@example.com", + onlineSince: activityTime - 47 * 60_000, + lastActivityAt: activityTime - 2 * 60_000, + deviceFamily: "Mac", + platform: "macOS", + timeZone: "America/Los_Angeles", + watchedSessions: ["agent:activity:design-review"], + }, + { + id: "presence-patricia", + email: "patricia.erichsen@example.com", + onlineSince: activityTime - 12 * 60_000, + lastActivityAt: activityTime - 30_000, + deviceFamily: "iPhone", + platform: "iOS", + timeZone: "Europe/Stockholm", + watchedSessions: ["agent:activity:support-handoff"], + }, ], methodResponses: { ...buildBackgroundTasksMock(baseTime), diff --git a/src/agents/openclaw-plugin-tools.ts b/src/agents/openclaw-plugin-tools.ts index 0bd5de21414e..5308a4a271d8 100644 --- a/src/agents/openclaw-plugin-tools.ts +++ b/src/agents/openclaw-plugin-tools.ts @@ -15,6 +15,7 @@ import { getPluginRuntimeGatewayRequestScope, withPluginRuntimeRegistryScope, } from "../plugins/runtime/gateway-request-scope.js"; +import { getPluginRuntimeLoadContext } from "../plugins/runtime/load-context.js"; import type { OpenClawPluginToolDelivery } from "../plugins/tool-types.js"; import { resolvePluginTools } from "../plugins/tools.js"; import type { OpenClawPluginToolContext } from "../plugins/types.js"; @@ -31,7 +32,6 @@ import { resolveOpenClawPluginToolInputs, type OpenClawPluginToolOptions, } from "./openclaw-tools.plugin-context.js"; -import { getPreparedPluginRuntimeLoadContext } from "./prepared-model-runtime.plugin-context.js"; import type { PreparedModelRuntimeSnapshot } from "./prepared-model-runtime.types.js"; import { resolveAgentRuntimeToolConfig } from "./tool-runtime-config.js"; import type { AnyAgentTool } from "./tools/common.js"; @@ -307,7 +307,7 @@ export function resolveOpenClawPluginToolsForOptions(params: { ...(preparedModelRuntime ? { preparedRuntime: { - loadContext: getPreparedPluginRuntimeLoadContext(preparedModelRuntime.pluginRegistry), + loadContext: getPluginRuntimeLoadContext(preparedModelRuntime.pluginRegistry), metadataSnapshot: preparedModelRuntime.metadataSnapshot, registry: preparedModelRuntime.pluginRegistry, }, diff --git a/src/agents/openclaw-tools.browser-plugin.integration.test.ts b/src/agents/openclaw-tools.browser-plugin.integration.test.ts index 09cbbed7372e..d7eabba2c371 100644 --- a/src/agents/openclaw-tools.browser-plugin.integration.test.ts +++ b/src/agents/openclaw-tools.browser-plugin.integration.test.ts @@ -19,15 +19,13 @@ import { loadWebMediaRaw } from "../media/web-media.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; +import { getPluginRuntimeLoadContext } from "../plugins/runtime/load-context.js"; import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } from "../secrets/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { getRuntimeAuthProfileStoreCredentialsRevision } from "./auth-profiles/runtime-snapshots.js"; import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js"; import { createOpenClawTools } from "./openclaw-tools.js"; -import { - getPreparedPluginRuntimeLoadContext, - prepareOwnedPluginLoadContext, -} from "./prepared-model-runtime.plugin-context.js"; +import { prepareOwnedPluginLoadContext } from "./prepared-model-runtime.plugin-context.js"; import { jsonResult } from "./tools/common.js"; const hoisted = vi.hoisted(() => ({ @@ -491,7 +489,7 @@ describe("createOpenClawTools browser plugin integration", () => { metadataSnapshot, ), ).toBe(metadataSnapshot); - const loadContext = getPreparedPluginRuntimeLoadContext(pluginRegistry); + const loadContext = getPluginRuntimeLoadContext(pluginRegistry); if (!loadContext) { throw new Error("expected prepared plugin load context"); } diff --git a/src/agents/prepared-model-runtime.inbound-registry.test.ts b/src/agents/prepared-model-runtime.inbound-registry.test.ts index 3e39c2d66a1a..32c00eda3618 100644 --- a/src/agents/prepared-model-runtime.inbound-registry.test.ts +++ b/src/agents/prepared-model-runtime.inbound-registry.test.ts @@ -10,6 +10,7 @@ import { createDeferred } from "../../test/helpers/promise.js"; import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { getPluginRuntimeGenerationRegistry } from "../plugins/runtime/generation-scope.js"; +import { getPluginRuntimeLoadContext } from "../plugins/runtime/load-context.js"; import { acquireAgentRunPreparedModelRuntime, getPreparedModelRuntimeSnapshot, @@ -17,7 +18,6 @@ import { registerPreparedModelRuntimePublicationListener, refreshPreparedModelRuntimeSnapshots, } from "./prepared-model-runtime.js"; -import { getPreparedPluginRuntimeLoadContext } from "./prepared-model-runtime.plugin-context.js"; const mocks = getPreparedModelRuntimeMocks(); @@ -197,7 +197,7 @@ describe("prepared reply dispatch runtime", () => { pluginGeneration: configuredRuntimeBefore.pluginGeneration, }); const dynamicSelectedBefore = dynamicLease.snapshot.pluginRegistry; - expect(getPreparedPluginRuntimeLoadContext(dynamicSelectedBefore)).toMatchObject({ + expect(getPluginRuntimeLoadContext(dynamicSelectedBefore)).toMatchObject({ preferBuiltPluginArtifacts: true, }); dynamicLease.release(); diff --git a/src/agents/prepared-model-runtime.plugin-context.test.ts b/src/agents/prepared-model-runtime.plugin-context.test.ts index 721c4dee9b89..a8776e5fc59e 100644 --- a/src/agents/prepared-model-runtime.plugin-context.test.ts +++ b/src/agents/prepared-model-runtime.plugin-context.test.ts @@ -7,10 +7,8 @@ import * as currentPluginMetadata from "../plugins/current-plugin-metadata-snaps import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js"; import * as pluginMetadata from "../plugins/plugin-metadata-snapshot.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; -import { - getPreparedPluginRuntimeLoadContext, - prepareOwnedPluginLoadContext, -} from "./prepared-model-runtime.plugin-context.js"; +import { getPluginRuntimeLoadContext } from "../plugins/runtime/load-context.js"; +import { prepareOwnedPluginLoadContext } from "./prepared-model-runtime.plugin-context.js"; import { withPreparedPluginGenerationScope } from "./prepared-model-runtime.plugin-generation.js"; describe("prepared model runtime plugin metadata ownership", () => { @@ -45,7 +43,7 @@ describe("prepared model runtime plugin metadata ownership", () => { expect( prepareOwnedPluginLoadContext(input, process.env, registry, gatewaySnapshot, true), ).toBe(gatewaySnapshot); - expect(getPreparedPluginRuntimeLoadContext(registry)).toMatchObject({ + expect(getPluginRuntimeLoadContext(registry)).toMatchObject({ metadataSnapshot: gatewaySnapshot, preferBuiltPluginArtifacts: true, }); @@ -86,7 +84,7 @@ describe("prepared model runtime plugin metadata ownership", () => { registry, ), ).toBe(directSnapshot); - expect(getPreparedPluginRuntimeLoadContext(registry)).toMatchObject({ + expect(getPluginRuntimeLoadContext(registry)).toMatchObject({ metadataSnapshot: directSnapshot, preferBuiltPluginArtifacts: false, }); diff --git a/src/agents/prepared-model-runtime.plugin-context.ts b/src/agents/prepared-model-runtime.plugin-context.ts index ee5d1a85de2d..b74e8813e622 100644 --- a/src/agents/prepared-model-runtime.plugin-context.ts +++ b/src/agents/prepared-model-runtime.plugin-context.ts @@ -5,25 +5,14 @@ import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot import type { PluginRegistry } from "../plugins/registry-types.js"; import { resolvePluginRuntimeLoadContext, + setPluginRuntimeLoadContext, type PluginRuntimeLoadContext, } from "../plugins/runtime/load-context.js"; import { createAgentRuntimeMetadataPluginIdScope } from "./harness/runtime-plugin-load-plan.js"; import type { PreparedModelRuntimeInput } from "./prepared-model-runtime.types.js"; -const preparedPluginRuntimeLoadContext = Symbol("preparedPluginRuntimeLoadContext"); const emptyPluginDiscovery: PluginDiscoveryResult = { candidates: [], diagnostics: [] }; -type PreparedPluginRegistry = PluginRegistry & { - [preparedPluginRuntimeLoadContext]?: PluginRuntimeLoadContext; -}; - -function setPreparedPluginRuntimeLoadContext( - registry: PluginRegistry, - context: PluginRuntimeLoadContext, -): void { - (registry as PreparedPluginRegistry)[preparedPluginRuntimeLoadContext] = context; -} - function preparePluginLoadContext( input: PreparedModelRuntimeInput, env: NodeJS.ProcessEnv, @@ -52,7 +41,7 @@ function preparePluginLoadContext( }; if (registry) { // The prepared registry is the lifecycle-owned carrier; standalone callers keep the cold path. - setPreparedPluginRuntimeLoadContext(registry, context); + setPluginRuntimeLoadContext(registry, context); } return context; } @@ -94,9 +83,3 @@ function resolveColdMetadataSnapshot( }); return resolvedMetadataSnapshot; } - -/** Reads plugin facts carried by a lifecycle-owned prepared runtime snapshot. */ -export const getPreparedPluginRuntimeLoadContext = ( - registry: PluginRegistry | undefined, -): PluginRuntimeLoadContext | undefined => - (registry as PreparedPluginRegistry | undefined)?.[preparedPluginRuntimeLoadContext]; diff --git a/src/agents/prepared-model-runtime.test.ts b/src/agents/prepared-model-runtime.test.ts index 9d0c14bdd6d2..400cc8cfde9f 100644 --- a/src/agents/prepared-model-runtime.test.ts +++ b/src/agents/prepared-model-runtime.test.ts @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { requireActivePluginRegistry } from "../plugins/runtime.js"; +import { getPluginRuntimeLoadContext } from "../plugins/runtime/load-context.js"; import { getPreparedModelRuntimeAuthStore } from "./prepared-model-runtime-auth.js"; import { startSerializedSnapshotBuild } from "./prepared-model-runtime.build.js"; import { prepareWorkspacePluginRegistries } from "./prepared-model-runtime.inbound-registry.js"; @@ -23,7 +24,6 @@ import { rejectPendingPreparedModelRuntimeReplacement, refreshPreparedModelRuntimeSnapshots, } from "./prepared-model-runtime.js"; -import { getPreparedPluginRuntimeLoadContext } from "./prepared-model-runtime.plugin-context.js"; const mocks = getPreparedModelRuntimeMocks(); @@ -294,7 +294,7 @@ describe("prepared model runtime snapshots", () => { env, }); - expect(getPreparedPluginRuntimeLoadContext(snapshot.pluginRegistry)).toMatchObject({ + expect(getPluginRuntimeLoadContext(snapshot.pluginRegistry)).toMatchObject({ rawConfig: config, env, }); diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index 7c8ce8788c2c..07facb75b525 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -748,20 +748,12 @@ describe("gateway broadcaster", () => { expectSentEvents(pairingSocket, [ "heartbeat", - "presence", - "health", - "tick", - "shutdown", - "update.available", - ]); - expectSentEvents(nodeSocket, [ - "heartbeat", - "presence", "health", "tick", "shutdown", "update.available", ]); + expectSentEvents(nodeSocket, ["heartbeat", "health", "tick", "shutdown", "update.available"]); expectSentEvents(readSocket, [ "cron", "voicewake.changed", @@ -776,7 +768,6 @@ describe("gateway broadcaster", () => { expectSentEvents(talkSocket, [ "talk.mode", "heartbeat", - "presence", "health", "tick", "shutdown", @@ -807,9 +798,14 @@ describe("gateway broadcaster", () => { readSocket, ); - const { broadcast } = createGatewayBroadcaster({ clients }); + const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ + clients, + preparePresenceProjection: (presence) => () => presence, + }); broadcast("chat", chatPayload()); + broadcast("presence", { presence: [] }); + broadcastToConnIds("presence", { presence: [] }, new Set(["c-pairing", "c-read"])); broadcast("heartbeat", { ts: 1 }); broadcast("chat.side_result", chatSideResultPayload()); broadcast("tick", { ts: 2 }); @@ -820,9 +816,11 @@ describe("gateway broadcaster", () => { ]); expect(sentEventSeq(readSocket)).toEqual([ ["chat", 1], - ["heartbeat", 2], - ["chat.side_result", 3], - ["tick", 4], + ["presence", 2], + ["presence", 3], + ["heartbeat", 4], + ["chat.side_result", 5], + ["tick", 6], ]); }); diff --git a/src/gateway/presence-projection.ts b/src/gateway/presence-projection.ts index 20fc04b706ba..6d5f0414d4e6 100644 --- a/src/gateway/presence-projection.ts +++ b/src/gateway/presence-projection.ts @@ -44,20 +44,26 @@ export function createPresenceRecipientProjection(params: { return targets.get(sessionKey); }; return (client) => { - const canRead = - client?.connect != null && - (client.connect.role ?? "operator") === "operator" && - authorizeOperatorScopesForRequiredScope(READ_SCOPE, client.connect.scopes ?? []).allowed && + // Match system-presence RPC access before projecting any rows: even idle + // people expose timing through ts and roster ordering, not just named fields. + if ( + !client?.connect || + (client.connect.role ?? "operator") !== "operator" || + !authorizeOperatorScopesForRequiredScope(READ_SCOPE, client.connect.scopes ?? []).allowed + ) { + return []; + } + const canReadSessions = // Match session reads: an established admin grant does not depend on profile verification. - (isGatewayAdmin(client) || !isGatewayClientProfilePending(client)); - const entryFilter = canRead + isGatewayAdmin(client) || !isGatewayClientProfilePending(client); + const entryFilter = canReadSessions ? createSessionListEntryFilter({ cfg: params.cfg, client }) : undefined; return params.presence.map((row) => { if (!row.watchedSessions) { return row; } - const watchedSessions = canRead + const watchedSessions = canReadSessions ? row.watchedSessions.filter((key) => { const target = resolveTarget(key); return target && (entryFilter?.(target.canonicalKey, target.entry) ?? true); diff --git a/src/gateway/server-broadcast.serialization.test.ts b/src/gateway/server-broadcast.serialization.test.ts index 428ff31917a2..fb8ae54137d9 100644 --- a/src/gateway/server-broadcast.serialization.test.ts +++ b/src/gateway/server-broadcast.serialization.test.ts @@ -283,11 +283,14 @@ describe("presence recipient projection", () => { const person = { text: "watcher", ts: 42, + onlineSince: 30, + lastActivityAt: 40, + timeZone: "Europe/Vienna", instanceId: "watcher", user: { id: "creator", name: "Creator" }, }; const watcher = { ...person, watchedSessions: [...keys, "agent:main:deleted"] }; - const presence = [watcher, { text: "idle", ts: 41 }]; + const presence = [watcher, { ...person, text: "idle", instanceId: "idle", ts: 41 }]; Object.freeze(watcher.watchedSessions); presence.forEach(Object.freeze); Object.freeze(presence); @@ -373,8 +376,16 @@ describe("presence recipient projection", () => { createdActor: { type: "human", id: "creator" }, }, ); - const person = { text: "watcher", ts: 1 }; - const presence = [{ ...person, watchedSessions: [key] }]; + const person = { + text: "watcher", + ts: 3, + onlineSince: 1, + lastActivityAt: 2, + timeZone: "Europe/Vienna", + user: { id: "creator" }, + }; + const idle = { ...person, text: "idle" }; + const presence = [{ ...person, watchedSessions: [key] }, idle]; const solo = makeClient("solo").client; solo.connect.scopes = ["operator.write"]; const pending = makeClient("pending").client; @@ -387,16 +398,25 @@ describe("presence recipient projection", () => { node.connect.scopes = ["operator.admin"]; const noRead = makeClient("no-read").client; noRead.connect.scopes = []; + const worker = makeClient("worker").client; + worker.connect.role = "worker"; + worker.connect.scopes = []; + worker.connectionKind = "worker"; const project = createPresenceRecipientProjection({ cfg: {}, presence }); expect(project(solo)).toEqual(presence); - for (const client of [pending, node, noRead, null]) { - expect(project(client)).toEqual([person]); + solo.connect.scopes = []; + expect(project(solo)).toEqual([]); + solo.connect.scopes = ["operator.write"]; + expect(project(solo)).toEqual(presence); + expect(project(pending)).toEqual([person, idle]); + for (const client of [node, worker, noRead, null]) { + expect(project(client)).toEqual([]); } pending.connect.scopes = ["operator.admin"]; expect(project(pending)).toEqual(presence); const cfg: OpenClawConfig = { gateway: { roles: { definitions: {} } } }; const restrictedProject = createPresenceRecipientProjection({ cfg, presence }); - expect(restrictedProject(solo)).toEqual([person]); + expect(restrictedProject(solo)).toEqual([person, idle]); solo.internal = { operatorRoleActor: { kind: "system" } }; expect(restrictedProject(solo)).toEqual(presence); pending.authenticatedUserProfile = { diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index 3feda2229816..4a2faa91ab9b 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -60,7 +60,8 @@ const EVENT_SCOPE_GUARDS: Record = { "plugin.approval.resolved": [APPROVALS_SCOPE], "openclaw.approval.requested": [APPROVALS_SCOPE], "openclaw.approval.resolved": [APPROVALS_SCOPE], - presence: [], + // The frame cadence itself exposes person activity; match system-presence access. + presence: [READ_SCOPE], shutdown: [], tick: [], "talk.event": [READ_SCOPE], diff --git a/src/gateway/server-core-runtime.ts b/src/gateway/server-core-runtime.ts index 05ea67fb370f..a921fa1aa763 100644 --- a/src/gateway/server-core-runtime.ts +++ b/src/gateway/server-core-runtime.ts @@ -583,6 +583,12 @@ export async function startGatewayCoreRuntime(input: { channelManager.setAmbientAutostartSuppressedChannelIds( nextAmbientAutostartSuppressedChannelIds, ); + const nextPluginMetadataSnapshot = completePluginMetadataSnapshot({ + snapshot: nextPluginLookUpTable, + config: params.sourceConfig, + env: params.env, + workspaceDir: pluginWorkspaceDir, + }); loaded = prepareGatewayPluginLoad({ cfg: params.nextConfig, activationSourceConfig: params.sourceConfig, @@ -592,15 +598,10 @@ export async function startGatewayCoreRuntime(input: { hostServices: pluginHostServices, baseMethods, pluginLookUpTable: nextPluginLookUpTable, + pluginMetadataSnapshot: nextPluginMetadataSnapshot, ambientEnvTriggers, resolveGatewayContext: resolvePluginGatewayContext, }); - const nextPluginMetadataSnapshot = completePluginMetadataSnapshot({ - snapshot: nextPluginLookUpTable, - config: params.sourceConfig, - env: params.env, - workspaceDir: pluginWorkspaceDir, - }); setCurrentPluginMetadataSnapshot(nextPluginMetadataSnapshot, { config: params.sourceConfig, compatibleConfigs: [params.nextConfig], diff --git a/src/gateway/server-lifecycle.ts b/src/gateway/server-lifecycle.ts index 833f389d7b71..9d0a78dfd953 100644 --- a/src/gateway/server-lifecycle.ts +++ b/src/gateway/server-lifecycle.ts @@ -334,17 +334,10 @@ export async function prepareGatewayLifecycle(params: { isConnectionActive, }); runtimeState.sessionViewerPresence = createSessionViewerPresenceDeclarations({ - isConnectionActive, - onReplace: (connId, sessionKeys) => { - const client = clients.getByConnectionId(connId); - if (!client?.presenceKey) { - return; - } - upsertPresence(client.presenceKey, { - watchedSessions: sessionKeys.length > 0 ? [...sessionKeys] : undefined, - }); - broadcastPresenceSnapshot({ broadcast, incrementPresenceVersion, getHealthVersion }); - }, + clients, + broadcast, + incrementPresenceVersion, + getHealthVersion, }); deps.cron = runtimeState.cronState.cron; const pluginHostServices = { diff --git a/src/gateway/server-methods/chat-send-handler.ts b/src/gateway/server-methods/chat-send-handler.ts index 75e1a44c4938..2a65b8d90047 100644 --- a/src/gateway/server-methods/chat-send-handler.ts +++ b/src/gateway/server-methods/chat-send-handler.ts @@ -274,6 +274,7 @@ async function handleChatSendWithOptions( // post-ACK cleanupAdmittedRun must not race that persist with a discard. admitted.value.setDiscardAbandonedPreparedMedia(undefined); respond(true, ackPayload, undefined, { runId: clientRunId }); + context.recordClientActivity?.(client); const chatSendAckedAtMs = chatSendTiming?.ackedAtMs ?? performance.now(); startChatDispatch({ admissionStartedAt, diff --git a/src/gateway/server-methods/chat.error-broadcast.test.ts b/src/gateway/server-methods/chat.error-broadcast.test.ts index 76fb05321394..c0ef7411499a 100644 --- a/src/gateway/server-methods/chat.error-broadcast.test.ts +++ b/src/gateway/server-methods/chat.error-broadcast.test.ts @@ -2,10 +2,21 @@ // error-state broadcasts for connected UI clients. import { expectDefined } from "@openclaw/normalization-core"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { createDeferred } from "../../../test/helpers/promise.js"; +import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; import { createChatRunState } from "../server-chat-state.js"; +import { recordClientPresenceActivity } from "../server/client-presence.js"; +import type { GatewayWsClient } from "../server/ws-types.js"; +import { handleChatSend } from "./chat-send-handler.js"; import { chatHandlers } from "./chat.js"; -import type { GatewayRequestContext } from "./types.js"; +import type { GatewayClient, GatewayRequestContext } from "./types.js"; + +vi.mock("./chat-send-agent-dispatch.js", () => ({ + startChatDispatch: () => { + throw new Error("dispatch failed after admission ACK"); + }, +})); function createMockContext() { const broadcast = vi.fn(); @@ -25,6 +36,7 @@ function createMockContext() { logGateway: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, addChatRun: vi.fn(), removeChatRun: vi.fn(), + recordClientActivity: vi.fn<(client: GatewayClient | null) => void>(), }; } @@ -60,6 +72,7 @@ describe("chat.send error broadcast", () => { ); expect(ctx.addChatRun).not.toHaveBeenCalled(); expect(ctx.broadcast).not.toHaveBeenCalled(); + expect(ctx.recordClientActivity).not.toHaveBeenCalled(); }); it("rejects a stale expected session routing contract before dispatch", async () => { @@ -127,8 +140,95 @@ describe("chat.send error broadcast", () => { undefined, { cached: true }, ); + expect(ctx.recordClientActivity).not.toHaveBeenCalled(); }); + it.each([false, true])( + "records new admission activity only if the socket remains live (closed=%s)", + async (closedDuringAdmission) => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + const ctx = createMockContext(); + const client: GatewayWsClient = { + connId: "activity-send", + presenceKey: "activity-send", + usesSharedGatewayAuth: false, + socket: { readyState: 1 } as GatewayWsClient["socket"], + connect: { + minProtocol: 1, + maxProtocol: 1, + role: "operator", + scopes: ["operator.admin"], + client: { id: "openclaw-tui", version: "test", platform: "test", mode: "cli" }, + }, + authenticatedUserId: "send@activity.test", + personPresence: { onlineSince: Date.now() - 1_000 }, + }; + const clients = new Set([client]); + ctx.recordClientActivity.mockImplementation((requestClient) => { + recordClientPresenceActivity(clients, requestClient); + }); + const entered = createDeferred(); + const release = createDeferred(); + onTestFinished(() => release.resolve()); + const respond = vi.fn(); + const options = { + params: { + sessionKey: "main", + message: "accepted activity", + idempotencyKey: "activity-send", + }, + client, + context: ctx as unknown as GatewayRequestContext, + req: { type: "req" as const, id: "activity-send", method: "chat.send" }, + isWebchatConnect: () => false, + respond, + }; + const sending = handleChatSend(options, async () => { + entered.resolve(); + await release.promise; + return true; + }); + await Promise.race([ + entered.promise, + sending.then(() => { + throw new Error("send finished before reaching admission"); + }), + ]); + expect(client.personPresence?.lastActivityAt).toBeUndefined(); + const duplicateResponse = vi.fn(); + await handleChatSend({ ...options, respond: duplicateResponse }); + expect(duplicateResponse).toHaveBeenCalledWith( + true, + { runId: "activity-send", status: "in_flight" }, + undefined, + expect.objectContaining({ cached: true }), + ); + expect(ctx.recordClientActivity).not.toHaveBeenCalled(); + if (closedDuringAdmission) { + clients.delete(client); + } + const admittedAt = Date.now() + 1_000; + const clock = vi.spyOn(Date, "now").mockReturnValue(admittedAt); + onTestFinished(() => clock.mockRestore()); + release.resolve(); + await sending; + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ runId: "activity-send", status: "started" }), + undefined, + { runId: "activity-send" }, + ); + expect(client.personPresence?.lastActivityAt).toBe( + closedDuringAdmission ? undefined : admittedAt, + ); + const cachedResponse = vi.fn(); + await handleChatSend({ ...options, respond: cachedResponse }); + expect(cachedResponse.mock.calls[0]?.[3]).toMatchObject({ cached: true }); + expect(ctx.recordClientActivity).toHaveBeenCalledExactlyOnceWith(client); + }); + }, + ); + it("rejects a stale routing contract before a stop side effect", async () => { const ctx = createMockContext(); const respond = vi.fn(); diff --git a/src/gateway/server-methods/sessions-suggestions.ts b/src/gateway/server-methods/sessions-suggestions.ts index 3e328d61a1b7..e389fc698a97 100644 --- a/src/gateway/server-methods/sessions-suggestions.ts +++ b/src/gateway/server-methods/sessions-suggestions.ts @@ -561,6 +561,9 @@ export const sessionSuggestionHandlers: GatewayRequestHandlers = { respond(true, { ok: true, broadcast: false }); return; } + if (params.typing) { + context.recordClientActivity?.(client); + } const sessionKeys = new Set([ params.sessionKey, target.canonicalKey, diff --git a/src/gateway/server-methods/sessions-typing.test.ts b/src/gateway/server-methods/sessions-typing.test.ts index f5e663e102b0..06bd44ee0870 100644 --- a/src/gateway/server-methods/sessions-typing.test.ts +++ b/src/gateway/server-methods/sessions-typing.test.ts @@ -252,6 +252,8 @@ describe("session typing handler", () => { ]; const broadcast = vi.fn(); const requestContext = context(broadcast); + const recordClientActivity = vi.fn(); + requestContext.recordClientActivity = recordClientActivity; const params = { sessionKey, sessionId: "session-main", context: requestContext }; const tabOne = client("multi", "multi-tab-1"); const tabTwo = client("multi", "multi-tab-2"); @@ -277,6 +279,9 @@ describe("session typing handler", () => { }); await vi.advanceTimersByTimeAsync(500); expect(broadcast.mock.calls.map((call) => call[1].typing)).toEqual([true, false]); + expect(recordClientActivity.mock.calls).toEqual([[tabOne], [tabTwo]]); + await vi.advanceTimersByTimeAsync(3_000); + expect(recordClientActivity).toHaveBeenCalledTimes(2); }); }); @@ -376,6 +381,8 @@ describe("session typing handler", () => { client: client("alice", "alice-tab"), context: context(broadcast), }; + const recordClientActivity = vi.fn(); + params.context.recordClientActivity = recordClientActivity; expect(await callTyping({ ...params, typing: true })).toEqual({ ok: true, @@ -394,6 +401,41 @@ describe("session typing handler", () => { }); await vi.advanceTimersByTimeAsync(900); expect(broadcast).toHaveBeenCalledTimes(1); + expect(recordClientActivity).toHaveBeenCalledTimes(2); + await callTyping({ ...params, typing: true }); + expect(recordClientActivity).toHaveBeenCalledTimes(2); + }); + }); + + it("does not record malformed, hidden, or unauthorized typing", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + const sessionKey = "agent:main:typing-activity-authorization"; + const scope = { agentId: "main", sessionKey }; + const entry = { + sessionId: "typing-activity", + updatedAt: 1, + createdActor: { type: "human" as const, id: "owner" }, + }; + await upsertSessionEntryCore(scope, { ...entry, visibility: "draft" }); + const requestContext = context(); + const recordClientActivity = vi.fn(); + requestContext.recordClientActivity = recordClientActivity; + const params = { + sessionKey, + sessionId: entry.sessionId, + typing: true, + client: client("viewer", "typing-viewer"), + context: requestContext, + }; + await callTyping({ ...params, sessionKey: "" }); + await callTyping(params); + expect(recordClientActivity).not.toHaveBeenCalled(); + await upsertSessionEntryCore(scope, { ...entry, visibility: "shared" }); + await callTyping(params); + expect(recordClientActivity).toHaveBeenCalledExactlyOnceWith(params.client); + await upsertSessionEntryCore(scope, { ...entry, visibility: "shared", incognito: true }); + await callTyping(params); + expect(recordClientActivity).toHaveBeenCalledExactlyOnceWith(params.client); }); }); }); diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 6ff4608334d5..8c6b36d8f090 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -340,6 +340,8 @@ type GatewayTransportContext = { nodeUnsubscribeAll: (nodeId: string) => void; hasConnectedTalkNode: () => Promise; isConnectionActive?: (connId: string) => boolean; + /** Server-stamped activity from an accepted request on the exact live person connection. */ + recordClientActivity?: (client: GatewayClient | null) => void; hasExecApprovalClients?: (excludeConnId?: string) => boolean; getApprovalClientConnIds?: (params?: { approvalKind?: "exec" | "plugin" | "system-agent"; diff --git a/src/gateway/server-plugin-bootstrap.ts b/src/gateway/server-plugin-bootstrap.ts index 64a80b0a7a75..1422d943b29c 100644 --- a/src/gateway/server-plugin-bootstrap.ts +++ b/src/gateway/server-plugin-bootstrap.ts @@ -6,6 +6,7 @@ import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ChannelPluginLoadIntent } from "../plugins/loader-types.js"; import type { PluginLookUpTable } from "../plugins/plugin-lookup-table.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import type { PluginRegistryParams } from "../plugins/registry-types.js"; import type { PluginRegistry } from "../plugins/registry.js"; import { @@ -41,6 +42,7 @@ type GatewayPluginBootstrapParams = { baseMethods: string[]; pluginIds?: string[]; pluginLookUpTable?: PluginLookUpTable; + pluginMetadataSnapshot?: PluginMetadataSnapshot; channelPluginLoadIntent?: ChannelPluginLoadIntent; suppressPluginInfoLogs?: boolean; logDiagnostics?: boolean; @@ -127,6 +129,7 @@ export function prepareGatewayPluginLoad(params: GatewayPluginBootstrapParams) { baseMethods: params.baseMethods, pluginIds: params.pluginIds, pluginLookUpTable: params.pluginLookUpTable, + pluginMetadataSnapshot: params.pluginMetadataSnapshot, channelPluginLoadIntent: params.channelPluginLoadIntent ?? "full", suppressPluginInfoLogs: params.suppressPluginInfoLogs, startupTrace: params.startupTrace, diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index ac8138ec43d7..26c7cf37fc85 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -59,9 +59,15 @@ vi.mock("../plugins/loader.js", () => ({ loadOpenClawPlugins, })); -vi.mock("../plugins/runtime/load-context.js", () => ({ - createPluginRuntimeLoaderLogger: () => pluginRuntimeLoaderLogger, -})); +vi.mock("../plugins/runtime/load-context.js", async (importOriginal) => { + const { buildPluginRuntimeLoadOptions, setPluginRuntimeLoadContext } = + await importOriginal(); + return { + buildPluginRuntimeLoadOptions, + setPluginRuntimeLoadContext, + createPluginRuntimeLoaderLogger: () => pluginRuntimeLoaderLogger, + }; +}); vi.mock("../plugins/plugin-lookup-table.js", () => ({ loadPluginLookUpTable, diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 661e01706479..1f0d0f1d544d 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -9,11 +9,13 @@ import { allowsProcessHomeSessionScan } from "../config/paths.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; +import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../plugins/installed-plugin-index-install-records.js"; import { activatePluginRegistry } from "../plugins/loader-shared.js"; import type { ChannelPluginLoadIntent } from "../plugins/loader-types.js"; import { loadAndActivateRootPluginRegistry } from "../plugins/loader.js"; import { loadPluginLookUpTable, type PluginLookUpTable } from "../plugins/plugin-lookup-table.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import { getPluginModuleLoaderStats } from "../plugins/plugin-module-loader-cache.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import type { PluginRegistryParams } from "../plugins/registry-types.js"; @@ -23,7 +25,12 @@ import { getPluginRuntimeGatewayRequestScope, withPluginRuntimeGatewayContextResolver, } from "../plugins/runtime/gateway-request-scope.js"; -import { createPluginRuntimeLoaderLogger } from "../plugins/runtime/load-context.js"; +import { + buildPluginRuntimeLoadOptions, + createPluginRuntimeLoaderLogger, + setPluginRuntimeLoadContext, + type PluginRuntimeLoadContext, +} from "../plugins/runtime/load-context.js"; import { resolvePluginSubagentCompletionRequester } from "../plugins/runtime/subagent-requester-context.js"; import type { CreatePluginRuntimeOptions, @@ -564,6 +571,7 @@ export function loadGatewayPlugins(params: { baseMethods: string[]; pluginIds?: string[]; pluginLookUpTable?: PluginLookUpTable; + pluginMetadataSnapshot?: PluginMetadataSnapshot; channelPluginLoadIntent?: ChannelPluginLoadIntent; suppressPluginInfoLogs?: boolean; startupTrace?: { @@ -588,28 +596,19 @@ export function loadGatewayPlugins(params: { : undefined; const autoEnableMs = performance.now() - started; const autoEnabled = - params.activationSourceConfig !== undefined + params.activationSourceConfig !== undefined || params.autoEnabledReasons !== undefined ? { config: params.cfg, - changes: activationAutoEnabled?.changes ?? [], autoEnabledReasons: params.autoEnabledReasons ?? activationAutoEnabled?.autoEnabledReasons ?? {}, } - : params.autoEnabledReasons !== undefined - ? { - config: params.cfg, - changes: [], - autoEnabledReasons: params.autoEnabledReasons, - } - : applyPluginAutoEnable({ - config: params.cfg, - env: process.env, - ...(params.pluginLookUpTable?.manifestRegistry - ? { manifestRegistry: params.pluginLookUpTable.manifestRegistry } - : {}), - discovery: params.pluginLookUpTable?.discovery, - ambientEnvTriggers: params.ambientEnvTriggers, - }); + : applyPluginAutoEnable({ + config: params.cfg, + env: process.env, + manifestRegistry: params.pluginLookUpTable?.manifestRegistry, + discovery: params.pluginLookUpTable?.discovery, + ambientEnvTriggers: params.ambientEnvTriggers, + }); const resolvedConfigMs = performance.now() - started; const resolvedConfig = autoEnabled.config; const pluginIds = params.pluginIds ?? [ @@ -625,8 +624,36 @@ export function loadGatewayPlugins(params: { ).startup.pluginIds, ]; const pluginIdsMs = performance.now() - started; + const metadataSnapshot = + params.pluginMetadataSnapshot ?? + getCurrentPluginMetadataSnapshot({ + config: params.cfg, + workspaceDir: params.workspaceDir, + }); + const loaderMetadata = metadataSnapshot ?? params.pluginLookUpTable; + const loadContext: PluginRuntimeLoadContext = { + rawConfig: params.cfg, + config: resolvedConfig, + activationSourceConfig: params.activationSourceConfig ?? params.cfg, + autoEnabledReasons: autoEnabled.autoEnabledReasons, + workspaceDir: params.workspaceDir, + env: process.env, + logger: createGatewayPluginRegistrationLogger({ + suppressInfoLogs: params.suppressPluginInfoLogs, + }), + preferBuiltPluginArtifacts: true, + metadataSnapshot, + ...(loaderMetadata + ? { + manifestRegistry: loaderMetadata.manifestRegistry, + installRecords: extractPluginInstallRecordsFromInstalledPluginIndex(loaderMetadata.index), + } + : {}), + }; if (pluginIds.length === 0) { const pluginRegistry = createEmptyPluginRegistry(); + // An empty startup registry still owns the artifact policy for later capability loads. + setPluginRuntimeLoadContext(pluginRegistry, loadContext); activatePluginRegistry(pluginRegistry, null, "gateway-bindable", params.workspaceDir); params.startupTrace?.detail("plugins.gateway-load", [ ["autoEnableMs", autoEnableMs], @@ -650,42 +677,22 @@ export function loadGatewayPlugins(params: { resolvePluginSubagentOverridePolicies(resolvedConfig), ); const pluginRegistry = loadAndActivateRootPluginRegistry({ - config: resolvedConfig, + ...buildPluginRuntimeLoadOptions(loadContext), + // Startup registration stays scoped; later capability loads use the complete bound generation. + manifestRegistry: params.pluginLookUpTable?.manifestRegistry ?? loadContext.manifestRegistry, allowProcessHomeSessionCatalogs, - activationSourceConfig: params.activationSourceConfig ?? params.cfg, - autoEnabledReasons: autoEnabled.autoEnabledReasons, - workspaceDir: params.workspaceDir, onlyPluginIds: pluginIds, - logger: createGatewayPluginRegistrationLogger({ - suppressInfoLogs: params.suppressPluginInfoLogs, - }), - ...(params.coreGatewayHandlers !== undefined && { - coreGatewayHandlers: params.coreGatewayHandlers, - }), - ...(params.coreGatewayMethodNames !== undefined && { - coreGatewayMethodNames: params.coreGatewayMethodNames, - }), - ...(params.hostServices !== undefined && { - hostServices: params.hostServices, - }), + coreGatewayHandlers: params.coreGatewayHandlers, + coreGatewayMethodNames: params.coreGatewayMethodNames, + hostServices: params.hostServices, runtimeOptions: { allowGatewaySubagentBinding: true, ...gatewayRuntimeBindings.runtime, }, channelPluginLoadIntent: params.channelPluginLoadIntent, - preferBuiltPluginArtifacts: true, - ...(params.startupTrace !== undefined && { - startupTrace: params.startupTrace, - }), - ...(params.pluginLookUpTable - ? { - manifestRegistry: params.pluginLookUpTable.manifestRegistry, - installRecords: extractPluginInstallRecordsFromInstalledPluginIndex( - params.pluginLookUpTable.index, - ), - } - : {}), + startupTrace: params.startupTrace, }); + setPluginRuntimeLoadContext(pluginRegistry, loadContext); const loadMs = performance.now() - beforeLoad; const loaderStatsAfter = getPluginModuleLoaderStats(); const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers); diff --git a/src/gateway/server-request-context.test.ts b/src/gateway/server-request-context.test.ts index 721db350dc76..998b6bc306aa 100644 --- a/src/gateway/server-request-context.test.ts +++ b/src/gateway/server-request-context.test.ts @@ -1,12 +1,13 @@ /** * Gateway request context construction tests. */ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; import { GATEWAY_CLIENT_CAPS, GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES, } from "../../packages/gateway-protocol/src/client-info.js"; +import { listSystemPresence } from "../infra/system-presence.js"; import { ensureProfileForEmail, getUserProfileDisplay, @@ -17,6 +18,7 @@ import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { createChatRunState } from "./server-chat-state.js"; import type { GatewayServerLiveState } from "./server-live-state.js"; import { createGatewayRequestContext } from "./server-request-context.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; type GatewayRequestContextParams = Parameters[0]; type TestCronState = GatewayServerLiveState["cronState"]; @@ -149,7 +151,7 @@ function makeGatewayClient(params: { scopes: params.scopes ?? [], caps: params.caps ?? [], }, - socket: { close: vi.fn() }, + socket: { close: vi.fn(), readyState: 1 }, ...(params.approvalRuntime ? { internal: { approvalRuntime: true } } : {}), ...(params.invalidated ? { invalidated: true } : {}), }; @@ -414,6 +416,18 @@ describe("createGatewayRequestContext", () => { updatedAt: source.updatedAt, }, presenceKey: "profile-refresh-merge-source", + personPresence: { onlineSince: 1_000, lastActivityAt: 2_000 }, + }; + const targetClient = { + ...sourceClient, + connId: "merge-target", + authenticatedUserId: "merge-target@example.test", + authenticatedUserProfile: { + ...sourceClient.authenticatedUserProfile, + profileId: target.id, + }, + presenceKey: "profile-refresh-merge-target", + personPresence: { onlineSince: 1_500, lastActivityAt: 3_000 }, }; const unrelatedClient = { ...makeGatewayClient({ @@ -432,7 +446,7 @@ describe("createGatewayRequestContext", () => { }; const capturedProfile = sourceClient.authenticatedUserProfile; const params = makeContextParams({ - clients: new Set([sourceClient, unrelatedClient]) as never, + clients: new Set([sourceClient, targetClient, unrelatedClient]) as never, }); const context = createGatewayRequestContext(params); @@ -453,6 +467,13 @@ describe("createGatewayRequestContext", () => { updatedAt: linked.updatedAt, }); expect(unrelatedClient.authenticatedUserProfile.profileId).toBe(unrelatedProfile.id); + for (const email of ["merge-source@example.test", "merge-target@example.test"]) { + expect(listSystemPresence().find((entry) => entry.user?.email === email)).toMatchObject({ + user: { id: target.id }, + onlineSince: 1_000, + lastActivityAt: 3_000, + }); + } const presence = vi.mocked(params.broadcast).mock.calls[0]?.[1] as { presence?: Array<{ user?: { id?: string; email?: string; avatarUrl?: string } }>; }; @@ -470,6 +491,79 @@ describe("createGatewayRequestContext", () => { }); }); + it("publishes only server-stamped activity from the exact live client", () => { + const now = vi.spyOn(Date, "now").mockReturnValue(10_000); + onTestFinished(() => now.mockRestore()); + const client: GatewayWsClient = { + ...makeGatewayClient({ connId: "activity-live", clientId: GATEWAY_CLIENT_IDS.CONTROL_UI }), + socket: { readyState: 1 } as GatewayWsClient["socket"], + usesSharedGatewayAuth: false, + presenceKey: "activity-live", + authenticatedUserId: "live@activity.test", + personPresence: { onlineSince: 9_000 }, + }; + const clients = new Set([client]); + const params = makeContextParams({ clients }); + const context = createGatewayRequestContext(params); + context.recordClientActivity?.({ ...client }); + expect(params.broadcast).not.toHaveBeenCalled(); + context.recordClientActivity?.(client); + expect(params.broadcast).toHaveBeenCalledExactlyOnceWith( + "presence", + { + presence: expect.arrayContaining([ + expect.objectContaining({ + user: { id: "live@activity.test", email: "live@activity.test" }, + onlineSince: 9_000, + lastActivityAt: 10_000, + }), + ]), + }, + { dropIfSlow: true, stateVersion: { presence: 1, health: 1 } }, + ); + now.mockReturnValue(11_000); + clients.delete(client); + context.recordClientActivity?.(client); + expect(params.broadcast).toHaveBeenCalledOnce(); + }); + + it.each(["removed", "invalidated", "closing"] as const)( + "does not refresh a %s profile connection or resurrect its presence", + (state) => { + const client: GatewayWsClient = { + ...makeGatewayClient({ + connId: `profile-${state}`, + clientId: GATEWAY_CLIENT_IDS.CONTROL_UI, + }), + socket: { readyState: state === "closing" ? 2 : 1 } as GatewayWsClient["socket"], + usesSharedGatewayAuth: false, + authenticatedUserId: `${state}@profile.test`, + authenticatedUserProfile: { + profileId: `inactive-${state}`, + displayName: "Before", + avatarRevision: "1", + hasAvatar: false, + updatedAt: 1, + }, + presenceKey: `profile-${state}`, + invalidated: state === "invalidated", + }; + const params = makeContextParams({ clients: new Set(state === "removed" ? [] : [client]) }); + createGatewayRequestContext(params).refreshConnectedUserProfile?.({ + id: `inactive-${state}`, + displayName: "After", + avatarRevision: "2", + hasAvatar: false, + updatedAt: 2, + }); + expect(client.authenticatedUserProfile?.displayName).toBe("Before"); + expect(params.broadcast).not.toHaveBeenCalled(); + expect( + listSystemPresence().some((entry) => entry.user?.email === `${state}@profile.test`), + ).toBe(false); + }, + ); + it("preserves the Gravatar-backed route when a changed profile has no upload", () => { const client = { ...makeGatewayClient({ diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 83c4587d9903..43489d2d5461 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -7,15 +7,16 @@ import { type GatewayClientId, } from "../../packages/gateway-protocol/src/client-info.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { upsertPresence } from "../infra/system-presence.js"; import { resolveUserProfileId } from "../state/user-profiles.js"; -import { buildAuthenticatedPresenceUser } from "./authenticated-presence-user.js"; import { NODE_DESKTOP_SERVICE_CONTEXT } from "./desktop/node-source-context.js"; import { ScopeUpgradeCoordinator } from "./device-scope-upgrade.js"; +import { WEBSOCKET_OPEN_READY_STATE } from "./server-constants.js"; import type { GatewayServerLiveState } from "./server-live-state.js"; import type { GatewayClient, GatewayRequestContext } from "./server-methods/types.js"; import { disconnectAllSharedGatewayAuthClients } from "./server-shared-auth-generation.js"; +import { recordClientPresenceActivity, refreshClientPresence } from "./server/client-presence.js"; import { broadcastPresenceSnapshot } from "./server/presence-events.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; import type { SessionCompanionService } from "./session-companion.js"; import type { SessionObserverService } from "./session-observer-contract.js"; @@ -70,7 +71,7 @@ type GatewayRequestContextParams = { nodeUnsubscribe: GatewayRequestContext["nodeUnsubscribe"]; nodeUnsubscribeAll: GatewayRequestContext["nodeUnsubscribeAll"]; hasConnectedTalkNode: GatewayRequestContext["hasConnectedTalkNode"]; - clients: Set; + clients: Set; isConnectionActive: NonNullable; invalidateDeviceTransports?: ( deviceId: string, @@ -230,6 +231,11 @@ export function createGatewayRequestContext( nodeUnsubscribeAll: params.nodeUnsubscribeAll, hasConnectedTalkNode: params.hasConnectedTalkNode, isConnectionActive: params.isConnectionActive, + recordClientActivity: (client) => { + if (recordClientPresenceActivity(params.clients, client)) { + broadcastPresenceSnapshot(params); + } + }, hasExecApprovalClients: (excludeConnId?: string) => { for (const gatewayClient of params.clients) { if (excludeConnId && gatewayClient.connId === excludeConnId) { @@ -284,6 +290,12 @@ export function createGatewayRequestContext( refreshConnectedUserProfile: (profile) => { let presenceChanged = false; for (const gatewayClient of params.clients) { + if ( + gatewayClient.invalidated || + gatewayClient.socket.readyState !== WEBSOCKET_OPEN_READY_STATE + ) { + continue; + } const authenticatedUserProfile = gatewayClient.authenticatedUserProfile; if (!authenticatedUserProfile) { continue; @@ -302,22 +314,7 @@ export function createGatewayRequestContext( hasAvatar: profile.hasAvatar, updatedAt: profile.updatedAt, }); - if (!gatewayClient.presenceKey || !gatewayClient.authenticatedUserId) { - continue; - } - upsertPresence(gatewayClient.presenceKey, { - user: buildAuthenticatedPresenceUser({ - authenticatedUserId: gatewayClient.authenticatedUserId, - authenticatedUserIsTailscaleProvider: - gatewayClient.authenticatedUserIsTailscaleProvider, - authenticatedUserProfile: { - profileId: profile.id, - displayName: profile.displayName, - avatarRevision: profile.avatarRevision, - }, - }), - }); - presenceChanged = true; + presenceChanged = refreshClientPresence(params.clients, gatewayClient) || presenceChanged; } if (presenceChanged) { broadcastPresenceSnapshot({ diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index b4acb71f27c5..6894319bd707 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -405,8 +405,8 @@ export function registerDefaultAuthTokenSuite(): void { const { randomUUID } = await import("node:crypto"); const os = await import("node:os"); const path = await import("node:path"); - // Fresh identity: avoid leaking prior scopes (presence merges lists). - const { identity, device } = await createSignedDevice({ + // Fresh identity avoids inheriting a previously paired device's grant. + const { device } = await createSignedDevice({ token, scopes: [], clientId: GATEWAY_CLIENT_NAMES.TEST, @@ -421,22 +421,12 @@ export function registerDefaultAuthTokenSuite(): void { device, }); expect(connectRes.ok).toBe(true); - const helloOk = connectRes.payload as - | { - snapshot?: { - presence?: Array<{ deviceId?: unknown; scopes?: unknown }>; - }; - } - | undefined; - const presence = helloOk?.snapshot?.presence; - expect(Array.isArray(presence)).toBe(true); - const mine = presence?.find((entry) => entry.deviceId === identity.deviceId); - if (!mine) { - throw new Error(`expected presence entry for device ${identity.deviceId}`); - } - const presenceScopes = Array.isArray(mine?.scopes) ? mine?.scopes : []; - expect(presenceScopes).toEqual([]); - expect(presenceScopes).not.toContain("operator.admin"); + expect(readHelloOkAuth(connectRes.payload)).toMatchObject({ role: "operator", scopes: [] }); + expect(connectRes.payload).toMatchObject({ snapshot: { presence: [] } }); + const presence = await rpcReq(ws, "system-presence"); + expect(presence.ok).toBe(false); + expect(presence.error?.message).toBe("missing scope: operator.read"); + expect(presence.payload).toBeUndefined(); await expectStatusMissingScopeButHealthAvailable(ws); diff --git a/src/gateway/server.auth.identity-scopes.test.ts b/src/gateway/server.auth.identity-scopes.test.ts index 424d4871e102..452791475ab1 100644 --- a/src/gateway/server.auth.identity-scopes.test.ts +++ b/src/gateway/server.auth.identity-scopes.test.ts @@ -1,24 +1,18 @@ import { randomUUID } from "node:crypto"; import os from "node:os"; import path from "node:path"; -import { Type } from "typebox"; -import { Value } from "typebox/value"; import { afterEach, describe, expect, test } from "vitest"; -import { PresenceEntrySchema } from "../../packages/gateway-protocol/src/schema/snapshot.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { writeConfigFile } from "../config/config.js"; -import { upsertSessionEntryCore } from "../config/sessions/session-accessor.js"; import type { GatewayAuthConfig, GatewayOperatorRolesConfig } from "../config/types.gateway.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { getPairedDevice, listDevicePairing } from "../infra/device-pairing.js"; -import { listSystemPresence, type SystemPresence } from "../infra/system-presence.js"; import { ensureProfileForEmail, setUserProfileRole } from "../state/user-profiles.js"; import { connectReq, CONTROL_UI_CLIENT, installGatewayTestHooks, NODE_CLIENT, - onceMessage, openTailscaleWs, openWs, rpcReq, @@ -65,190 +59,6 @@ function responseScopes(response: Awaited>): strin } describe("gateway identity scope grants", () => { - test("projects watched sessions for each authenticated presence recipient across hello, RPC, and events", async () => { - await configureGatewayAuth( - { - mode: "trusted-proxy", - identityScopes: { "admin@example.com": ["operator.admin"] }, - trustedProxy: { - userHeader: "x-forwarded-user", - requiredHeaders: ["x-forwarded-proto"], - allowLoopback: true, - }, - }, - { - roles: { - default: "reader", - definitions: { - reader: { sessions: { others: "view" }, agents: "*", scopes: ["operator.read"] }, - restricted: { sessions: { others: "none" }, agents: "*", scopes: ["operator.read"] }, - maintainer: { sessions: { others: "write" }, agents: "*", scopes: ["operator.admin"] }, - pairing: { sessions: { others: "none" }, agents: [], scopes: ["operator.pairing"] }, - }, - }, - }, - ); - const creator = ensureProfileForEmail("creator@example.com"); - const restricted = ensureProfileForEmail("restricted@example.com"); - setUserProfileRole(restricted.id, "restricted"); - setUserProfileRole(ensureProfileForEmail("admin@example.com").id, "maintainer"); - setUserProfileRole(ensureProfileForEmail("pairing@example.com").id, "pairing"); - const sharedKey = "agent:main:presence-shared"; - const draftKey = "agent:main:presence-draft"; - const incognitoKey = "agent:main:dashboard:incognito-presence"; - const restrictedKey = "agent:main:presence-restricted-draft"; - const missingKey = "agent:main:presence-missing"; - const watchedKeys = [sharedKey, draftKey, incognitoKey, restrictedKey, missingKey].toSorted(); - const watcherInstanceId = `presence-watcher-${randomUUID()}`; - const identityDir = tempDirs.make("openclaw-presence-identities-"); - - await withGatewayServer(async ({ port }) => { - for (const [sessionKey, profileId, visibility, incognito] of [ - [sharedKey, creator.id, "shared", false], - [draftKey, creator.id, "draft", false], - [incognitoKey, creator.id, "shared", true], - [restrictedKey, restricted.id, "draft", false], - ] as const) { - await upsertSessionEntryCore( - { agentId: "main", sessionKey }, - { - sessionId: randomUUID(), - updatedAt: Date.now(), - createdActor: { type: "human", id: profileId }, - visibility, - ...(incognito ? { incognito: true } : {}), - }, - ); - } - const sockets: Awaited>[] = []; - const openRecipient = async ( - name: string, - scopes: string[], - role: "operator" | "node" = "operator", - ) => { - const ws = await openWs(port, { - ...TRUSTED_PROXY_HEADERS, - "x-forwarded-user": `${name}@example.com`, - }); - sockets.push(ws); - const connected = await connectReq(ws, { - skipDefaultAuth: true, - prePairDevice: true, - scopes, - role, - client: { - ...(role === "node" ? NODE_CLIENT : CONTROL_UI_CLIENT), - instanceId: sockets.length === 1 ? watcherInstanceId : `presence-${name}`, - }, - deviceIdentityPath: path.join(identityDir, `${name}-${sockets.length}.sqlite`), - browserOrigin: BROWSER_ORIGIN, - }); - expect(connected.ok, `${name} connect: ${JSON.stringify(connected.error)}`).toBe(true); - expect(responseScopes(connected), `${name} effective scopes`).toEqual(scopes); - return { - ws, - hello: connected.payload as { snapshot: { presence: SystemPresence[] } }, - }; - }; - try { - const watcher = await openRecipient("admin", ["operator.admin"]); - const declared = await rpcReq(watcher.ws, "sessions.viewers.set", { - sessionKeys: watchedKeys, - }); - expect(declared).toMatchObject({ ok: true, payload: { sessionKeys: watchedKeys } }); - const rawWatcher = listSystemPresence().find( - (entry) => entry.instanceId === watcherInstanceId, - ); - expect(rawWatcher?.watchedSessions).toEqual(watchedKeys); - const { watchedSessions: _watchedSessions, ...person } = rawWatcher!; - expect(person.user?.id).toBe(ensureProfileForEmail("admin@example.com").id); - expect(person.ts).toBeGreaterThan(0); - - const recipients = []; - for (const scenario of [ - { name: "creator", scopes: ["operator.read"], allowed: [sharedKey, draftKey] }, - { name: "reader", scopes: ["operator.read"], allowed: [sharedKey] }, - { name: "restricted", scopes: ["operator.read"], allowed: [restrictedKey] }, - { - name: "admin", - scopes: ["operator.admin"], - allowed: [sharedKey, draftKey, incognitoKey, restrictedKey], - }, - { name: "pairing", scopes: ["operator.pairing"], allowed: [] }, - { name: "node", scopes: [], allowed: [] }, - ]) { - const recipient = await openRecipient( - scenario.name, - scenario.scopes, - scenario.name === "node" ? "node" : "operator", - ); - const canRead = scenario.name !== "pairing" && scenario.name !== "node"; - const listed = await rpcReq<{ sessions: Array<{ key: string }> }>( - recipient.ws, - "sessions.list", - { agentId: "main" }, - ); - expect(listed.ok, `${scenario.name} sessions.list scope`).toBe(canRead); - if (canRead) { - expect( - listed.payload?.sessions - .map((entry) => entry.key) - .filter((key) => watchedKeys.includes(key)) - .toSorted(), - `${scenario.name} canonical sessions.list visibility`, - ).toEqual(scenario.allowed.toSorted()); - } - const presence = await rpcReq(recipient.ws, "system-presence"); - expect(presence.ok, `${scenario.name} system-presence scope`).toBe(canRead); - recipients.push({ ...recipient, ...scenario, canRead, rpcPresence: presence.payload }); - } - - const eventPromises = recipients.map(({ ws }) => - onceMessage<{ type: string; event: string; payload: { presence: SystemPresence[] } }>( - ws, - (frame) => frame.type === "event" && frame.event === "presence", - ), - ); - expect( - await rpcReq(watcher.ws, "system-event", { text: "presence recipient repro" }), - ).toMatchObject({ ok: true }); - const events = await Promise.all(eventPromises); - for (const [index, recipient] of recipients.entries()) { - for (const [surface, rows] of [ - ["hello", recipient.hello.snapshot.presence], - ["system-presence", recipient.rpcPresence], - ["presence event", events[index]!.payload.presence], - ] as const) { - if (surface === "system-presence" && !recipient.canRead) { - continue; // The RPC is rejected for pairing-only operators and nodes. - } - if (!Value.Check(Type.Array(PresenceEntrySchema), rows)) { - throw new Error(`${recipient.name} ${surface} returned invalid presence rows`); - } - const received = rows.find((entry) => entry.instanceId === watcherInstanceId); - const { watchedSessions, ...receivedPerson } = received ?? {}; - expect - .soft( - receivedPerson, - `${recipient.name} ${surface} preserves the person and timestamp without hidden counts`, - ) - .toEqual(person); - expect - .soft( - watchedSessions ?? [], - `${recipient.name} ${surface} watched session disclosure`, - ) - .toEqual(recipient.allowed.toSorted()); - } - } - } finally { - for (const ws of sockets) { - ws.close(); - } - } - }); - }); - test.each([ { label: "unassigned default guest", diff --git a/src/gateway/server.auth.presence-audience.test.ts b/src/gateway/server.auth.presence-audience.test.ts new file mode 100644 index 000000000000..14f7ee041b0b --- /dev/null +++ b/src/gateway/server.auth.presence-audience.test.ts @@ -0,0 +1,380 @@ +import { randomUUID } from "node:crypto"; +import { once } from "node:events"; +import path from "node:path"; +import { rawDataToString } from "@openclaw/gateway-client/websocket-data"; +import { Type } from "typebox"; +import { Value } from "typebox/value"; +import { afterEach, describe, expect, test } from "vitest"; +import { PresenceEntrySchema } from "../../packages/gateway-protocol/src/schema/snapshot.js"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { writeConfigFile } from "../config/config.js"; +import { upsertSessionEntryCore } from "../config/sessions/session-accessor.js"; +import type { GatewayAuthConfig, GatewayOperatorRolesConfig } from "../config/types.gateway.js"; +import { listSystemPresence, type SystemPresence } from "../infra/system-presence.js"; +import { ensureProfileForEmail, setUserProfileRole } from "../state/user-profiles.js"; +import { + connectReq, + CONTROL_UI_CLIENT, + installGatewayTestHooks, + NODE_CLIENT, + onceMessage, + openWs, + rpcReq, + testState, + withGatewayServer, +} from "./server.auth.test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +const BROWSER_ORIGIN = "https://control.example.com"; +const TRUSTED_PROXY_HEADERS = { + origin: BROWSER_ORIGIN, + "x-forwarded-for": "203.0.113.50", + "x-forwarded-proto": "https", + "x-forwarded-user": "admin@example.com", +}; + +async function configureGatewayAuth( + auth: GatewayAuthConfig, + roles: GatewayOperatorRolesConfig, +): Promise { + testState.gatewayAuth = auth; + testState.gatewayControlUi = { allowedOrigins: [BROWSER_ORIGIN] }; + await writeConfigFile({ + gateway: { + auth, + trustedProxies: ["127.0.0.1"], + roles, + controlUi: { allowedOrigins: [BROWSER_ORIGIN] }, + }, + }); +} + +function responseScopes(response: Awaited>): string[] | undefined { + return (response.payload as { auth?: { scopes?: string[] } } | undefined)?.auth?.scopes; +} + +describe("gateway presence audience", () => { + test("shares people only with readers and filters their session references across hello, RPC, and activity events", async () => { + await configureGatewayAuth( + { + mode: "trusted-proxy", + identityScopes: { + "admin@example.com": ["operator.admin"], + "watcher@example.com": ["operator.admin"], + }, + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto"], + allowLoopback: true, + }, + }, + { + default: "reader", + definitions: { + reader: { sessions: { others: "view" }, agents: "*", scopes: ["operator.read"] }, + writer: { sessions: { others: "write" }, agents: "*", scopes: ["operator.write"] }, + restricted: { sessions: { others: "none" }, agents: "*", scopes: ["operator.read"] }, + maintainer: { sessions: { others: "write" }, agents: "*", scopes: ["operator.admin"] }, + pairing: { sessions: { others: "none" }, agents: [], scopes: ["operator.pairing"] }, + }, + }, + ); + const creator = ensureProfileForEmail("creator@example.com"); + const restricted = ensureProfileForEmail("restricted@example.com"); + setUserProfileRole(restricted.id, "restricted"); + setUserProfileRole(ensureProfileForEmail("admin@example.com").id, "maintainer"); + setUserProfileRole(ensureProfileForEmail("watcher@example.com").id, "maintainer"); + setUserProfileRole(ensureProfileForEmail("writer@example.com").id, "writer"); + setUserProfileRole(ensureProfileForEmail("pairing@example.com").id, "pairing"); + const sharedKey = "agent:main:presence-shared"; + const sharedSessionId = randomUUID(); + const draftKey = "agent:main:presence-draft"; + const incognitoKey = "agent:main:dashboard:incognito-presence"; + const restrictedKey = "agent:main:presence-restricted-draft"; + const missingKey = "agent:main:presence-missing"; + const watchedKeys = [sharedKey, draftKey, incognitoKey, restrictedKey, missingKey].toSorted(); + const watcherInstanceId = `presence-watcher-${randomUUID()}`; + const identityDir = tempDirs.make("openclaw-presence-identities-"); + + await withGatewayServer(async ({ port }) => { + for (const [sessionKey, profileId, visibility, incognito] of [ + [sharedKey, creator.id, "shared", false], + [draftKey, creator.id, "draft", false], + [incognitoKey, creator.id, "shared", true], + [restrictedKey, restricted.id, "draft", false], + ] as const) { + await upsertSessionEntryCore( + { agentId: "main", sessionKey }, + { + sessionId: sessionKey === sharedKey ? sharedSessionId : randomUUID(), + updatedAt: Date.now(), + createdActor: { type: "human", id: profileId }, + visibility, + ...(incognito ? { incognito: true } : {}), + }, + ); + } + const sockets: Awaited>[] = []; + const observePresence = (ws: Awaited>) => { + const events: SystemPresence[][] = []; + ws.on("message", (data) => { + const frame = JSON.parse(rawDataToString(data)) as { + type: string; + event?: string; + payload: { presence: SystemPresence[] }; + }; + if (frame.type === "event" && frame.event === "presence") { + events.push(frame.payload.presence); + } + }); + return events; + }; + const openRecipient = async ( + name: string, + scopes: string[], + role: "operator" | "node" = "operator", + ) => { + const ws = await openWs(port, { + ...TRUSTED_PROXY_HEADERS, + "x-forwarded-user": `${name}@example.com`, + }); + sockets.push(ws); + const events = observePresence(ws); + const client = { + ...(role === "node" ? NODE_CLIENT : CONTROL_UI_CLIENT), + instanceId: name === "watcher" ? watcherInstanceId : `presence-${name}`, + timeZone: "Europe/Vienna", + }; + const connected = await connectReq(ws, { + skipDefaultAuth: true, + prePairDevice: true, + scopes, + role, + client, + deviceIdentityPath: path.join(identityDir, `${name}-${sockets.length}.sqlite`), + browserOrigin: BROWSER_ORIGIN, + }); + expect(connected.ok, `${name} connect: ${JSON.stringify(connected.error)}`).toBe(true); + expect(responseScopes(connected), `${name} effective scopes`).toEqual(scopes); + return { + ws, + events, + hello: connected.payload as { snapshot: { presence: SystemPresence[] } }, + }; + }; + try { + const watcher = await openRecipient("watcher", ["operator.admin"]); + const idle = await openRecipient("idle", ["operator.read"]); + // A response on the watcher is a transport barrier, not a presence refresh. + expect((await rpcReq(watcher.ws, "health")).ok).toBe(true); + expect + .soft(watcher.events.at(-1), "first connect publishes without activity") + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ instanceId: "presence-idle", reason: "connect" }), + ]), + ); + for (const sessionKeys of [[sharedKey], []]) { + expect(await rpcReq(idle.ws, "sessions.viewers.set", { sessionKeys })).toMatchObject({ + ok: true, + }); + } + const idlePerson = listSystemPresence().find( + (entry) => entry.instanceId === "presence-idle", + )!; + expect(idlePerson.watchedSessions).toBeUndefined(); + expect(idlePerson).toMatchObject({ + onlineSince: expect.any(Number), + lastActivityAt: expect.any(Number), + timeZone: "Europe/Vienna", + }); + const declared = await rpcReq(watcher.ws, "sessions.viewers.set", { + sessionKeys: watchedKeys, + }); + expect(declared).toMatchObject({ ok: true, payload: { sessionKeys: watchedKeys } }); + const rawWatcher = listSystemPresence().find( + (entry) => entry.instanceId === watcherInstanceId, + ); + expect(rawWatcher?.watchedSessions).toEqual(watchedKeys); + const { watchedSessions: _watchedSessions, ...person } = rawWatcher!; + expect(person.user?.id).toBe(ensureProfileForEmail("watcher@example.com").id); + expect(person.ts).toBeGreaterThan(0); + expect(person).toMatchObject({ + onlineSince: expect.any(Number), + lastActivityAt: expect.any(Number), + timeZone: "Europe/Vienna", + }); + + const recipients = []; + for (const scenario of [ + { name: "creator", scopes: ["operator.read"], allowed: [sharedKey, draftKey] }, + { name: "reader", scopes: ["operator.read"], allowed: [sharedKey] }, + { name: "writer", scopes: ["operator.write"], allowed: [sharedKey] }, + { name: "restricted", scopes: ["operator.read"], allowed: [restrictedKey] }, + { + name: "admin", + scopes: ["operator.admin"], + allowed: [sharedKey, draftKey, incognitoKey, restrictedKey], + }, + { name: "pairing", scopes: ["operator.pairing"], allowed: [] }, + { name: "no-read", scopes: [], allowed: [] }, + { name: "node", scopes: [], allowed: [] }, + ]) { + const recipient = await openRecipient( + scenario.name, + scenario.scopes, + scenario.name === "node" ? "node" : "operator", + ); + const canRead = !["pairing", "node", "no-read"].includes(scenario.name); + const listed = await rpcReq<{ sessions: Array<{ key: string }> }>( + recipient.ws, + "sessions.list", + { agentId: "main" }, + ); + expect(listed.ok, `${scenario.name} sessions.list scope`).toBe(canRead); + if (canRead) { + expect( + listed.payload?.sessions + .map((entry) => entry.key) + .filter((key) => watchedKeys.includes(key)) + .toSorted(), + `${scenario.name} canonical sessions.list visibility`, + ).toEqual(scenario.allowed.toSorted()); + } + const presence = await rpcReq(recipient.ws, "system-presence"); + expect(presence.ok, `${scenario.name} system-presence scope`).toBe(canRead); + recipients.push({ ...recipient, ...scenario, canRead, rpcPresence: presence.payload }); + } + + const unauthenticated = await openWs(port, { origin: BROWSER_ORIGIN }); + sockets.push(unauthenticated); + const unauthenticatedEvents = observePresence(unauthenticated); + const readers = recipients.filter(({ canRead }) => canRead); + const eventPromises = readers.map(({ ws }) => + onceMessage<{ type: string; event: string; payload: { presence: SystemPresence[] } }>( + ws, + (frame) => frame.type === "event" && frame.event === "presence", + ), + ); + expect( + await rpcReq(watcher.ws, "session.typing", { + sessionKey: sharedKey, + sessionId: sharedSessionId, + typing: true, + }), + ).toMatchObject({ ok: true }); + const events = await Promise.all(eventPromises); + const activeWatcher = listSystemPresence().find( + (entry) => entry.instanceId === watcherInstanceId, + )!; + const { watchedSessions: _activeWatches, ...activePerson } = activeWatcher; + expect(activePerson.lastActivityAt).toBeGreaterThanOrEqual(person.lastActivityAt!); + expect(activeWatcher.watchedSessions).toEqual(watchedKeys); + for (const [index, recipient] of readers.entries()) { + for (const [surface, rows] of [ + ["hello", recipient.hello.snapshot.presence], + ["system-presence", recipient.rpcPresence], + ["presence event", events[index]!.payload.presence], + ] as const) { + if (!Value.Check(Type.Array(PresenceEntrySchema), rows)) { + throw new Error(`${recipient.name} ${surface} returned invalid presence rows`); + } + const received = rows.find((entry) => entry.instanceId === watcherInstanceId); + const { watchedSessions, ...receivedPerson } = received ?? {}; + expect + .soft( + receivedPerson, + `${recipient.name} ${surface} preserves the person and timestamp without hidden counts`, + ) + .toEqual(surface === "presence event" ? activePerson : person); + expect(rows.find((entry) => entry.instanceId === "presence-idle")).toEqual(idlePerson); + expect + .soft( + watchedSessions ?? [], + `${recipient.name} ${surface} watched session disclosure`, + ) + .toEqual(recipient.allowed.toSorted()); + } + } + for (const recipient of recipients.filter(({ canRead }) => !canRead)) { + expect(recipient.hello.snapshot.presence, `${recipient.name} hello inventory`).toEqual( + [], + ); + expect(recipient.rpcPresence).toBeUndefined(); + // The activity fanout is synchronous. A later response on this socket + // is a transport barrier, so absence does not depend on sleeping. + expect((await rpcReq(recipient.ws, "health")).ok).toBe(true); + expect(recipient.events, `${recipient.name} activity-driven frames`).toEqual([]); + } + const preauthRead = await rpcReq(unauthenticated, "system-presence"); + expect(preauthRead.ok).toBe(false); + expect(preauthRead.payload).toBeUndefined(); + expect(unauthenticatedEvents).toEqual([]); + + const liveIdleRows = async () => { + expect((await rpcReq(watcher.ws, "health")).ok).toBe(true); + return watcher.events + .at(-1)! + .filter( + (entry) => entry.user?.id === idlePerson.user?.id && entry.reason !== "disconnect", + ); + }; + const overlap = await openRecipient("idle", ["operator.read"]); + const overlappingRows = await liveIdleRows(); + expect.soft(overlappingRows, "overlapping connect publishes both sockets").toHaveLength(2); + for (const entry of overlappingRows) { + expect(entry.onlineSince).toBe(idlePerson.onlineSince); + expect(entry.lastActivityAt).toBe(idlePerson.lastActivityAt); + } + for (const [connection, remaining] of [ + [idle, 1], + [overlap, 0], + ] as const) { + const closed = once(connection.ws, "close"); + connection.ws.close(); + await closed; + const rows = await liveIdleRows(); + expect(rows, "disconnect publishes only the surviving sockets").toHaveLength(remaining); + if (remaining) { + expect(rows[0]?.onlineSince).toBe(idlePerson.onlineSince); + } + } + const reconnected = await openRecipient("idle", ["operator.read"]); + const returnedPerson = reconnected.hello.snapshot.presence.find( + (entry) => entry.user?.id === idlePerson.user?.id && entry.reason === "connect", + )!; + expect(returnedPerson.onlineSince).toBeGreaterThan(idlePerson.onlineSince!); + expect(returnedPerson.lastActivityAt).toBeUndefined(); + expect(returnedPerson.watchedSessions).toBeUndefined(); + expect( + await liveIdleRows(), + "reconnect publishes without profile edit or activity", + ).toEqual([returnedPerson]); + for (const recipient of recipients.filter(({ canRead }) => !canRead)) { + expect((await rpcReq(recipient.ws, "health")).ok).toBe(true); + expect(recipient.events, `${recipient.name} connection-driven frames`).toEqual([]); + } + + const rejected = await openWs(port, { origin: BROWSER_ORIGIN }); + sockets.push(rejected); + const rejectedEvents = observePresence(rejected); + const connect = await connectReq(rejected, { + skipDefaultAuth: true, + device: null, + scopes: ["operator.read", "operator.admin"], + client: CONTROL_UI_CLIENT, + }); + expect(connect.ok).toBe(false); + expect(connect.payload).toBeUndefined(); + expect(rejectedEvents).toEqual([]); + } finally { + for (const ws of sockets) { + ws.close(); + } + } + }); + }); +}); diff --git a/src/gateway/server.health.test.ts b/src/gateway/server.health.test.ts index 6319a382836b..a9a2a49faf6c 100644 --- a/src/gateway/server.health.test.ts +++ b/src/gateway/server.health.test.ts @@ -23,13 +23,14 @@ const FINGERPRINT_TIMEOUT_MS = 3_000; const CLI_PRESENCE_TIMEOUT_MS = 3_000; let harness: GatewayServerHarness; +let harnessClose: Promise | undefined; beforeAll(async () => { harness = await startGatewayServerHarness(); }); afterAll(async () => { - await harness.close(); + await (harnessClose ?? harness.close()); }); describe("gateway server health/presence", () => { @@ -186,20 +187,6 @@ describe("gateway server health/presence", () => { ws.close(); }); - test("shutdown event is broadcast on close", { timeout: PRESENCE_EVENT_TIMEOUT_MS }, async () => { - const localHarness = await startGatewayServerHarness(); - const { ws } = await localHarness.openClient(); - const shutdownP = onceMessage( - ws, - (o) => o.type === "event" && o.event === "shutdown", - SHUTDOWN_EVENT_TIMEOUT_MS, - ); - await localHarness.close(); - const evt = await shutdownP; - const evtPayload = evt.payload as { reason?: unknown } | undefined; - expect(evtPayload?.reason).toBe("gateway stopping"); - }); - test( "presence broadcast reaches multiple clients", { timeout: PRESENCE_EVENT_TIMEOUT_MS }, @@ -313,4 +300,19 @@ describe("gateway server health/presence", () => { ws.close(); }); + + // Close the suite owner last; another startup would reset process-wide config under live peers. + test("shutdown event is broadcast on close", { timeout: PRESENCE_EVENT_TIMEOUT_MS }, async () => { + const { ws } = await harness.openClient(); + const shutdownP = onceMessage( + ws, + (o) => o.type === "event" && o.event === "shutdown", + SHUTDOWN_EVENT_TIMEOUT_MS, + ); + harnessClose = harness.close(); + await harnessClose; + const evt = await shutdownP; + const evtPayload = evt.payload as { reason?: unknown } | undefined; + expect(evtPayload?.reason).toBe("gateway stopping"); + }); }); diff --git a/src/gateway/server/client-presence.test.ts b/src/gateway/server/client-presence.test.ts new file mode 100644 index 000000000000..00cf6ec504e7 --- /dev/null +++ b/src/gateway/server/client-presence.test.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + listSystemPresence, + updateSystemPresence, + upsertPresence, +} from "../../infra/system-presence.js"; +import { recordClientPresenceActivity, refreshClientPresence } from "./client-presence.js"; +import { GatewayClientRegistry } from "./client-registry.js"; +import { attachGatewayWsConnectionHandler } from "./ws-connection.js"; +import { + attachGatewayWsForTest, + createGatewayWsTestRequestContext, +} from "./ws-connection.test-helpers.js"; +import type { GatewayWsMessageHandlerParams } from "./ws-connection/message-handler.js"; +import type { GatewayWsClient } from "./ws-types.js"; + +const { attachMessageHandler } = vi.hoisted(() => ({ + attachMessageHandler: vi.fn<(params: GatewayWsMessageHandlerParams) => void>(), +})); +vi.mock("./ws-connection/message-handler.js", () => ({ + attachGatewayWsMessageHandler: attachMessageHandler, +})); + +describe("live person presence timing", () => { + const clients = new GatewayClientRegistry(); + const sockets: ReturnType["socket"][] = []; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2040-01-01T00:00:00Z")); + attachMessageHandler.mockClear(); + }); + afterEach(() => { + for (const socket of sockets.splice(0)) { + socket.readyState = 3; + socket.emit("close", 1000, Buffer.alloc(0)); + } + clients.clear(); + vi.setSystemTime(Date.now() + 300_001); + listSystemPresence(); + vi.useRealTimers(); + }); + + async function connect(email: string, profileId = "timing-person") { + const { socket } = attachGatewayWsForTest({ + attach: attachGatewayWsConnectionHandler, + clients, + options: { + buildRequestContext: () => + createGatewayWsTestRequestContext({ + nodeRegistry: { get: vi.fn(() => undefined), unregister: vi.fn(() => null) } as never, + }) as never, + }, + }); + sockets.push(socket); + await vi.dynamicImportSettled(); + const handler = attachMessageHandler.mock.lastCall?.[0]; + if (!handler) { + throw new Error("message handler was not attached"); + } + const client: GatewayWsClient = { + socket: socket as unknown as GatewayWsClient["socket"], + connId: handler.connId, + presenceKey: handler.connId, + usesSharedGatewayAuth: false, + connect: { + minProtocol: 1, + maxProtocol: 1, + role: "operator", + client: { id: "openclaw-control-ui", version: "test", platform: "test", mode: "webchat" }, + }, + authenticatedUserId: email, + authenticatedUserProfile: { + profileId, + displayName: "Timing Person", + avatarRevision: "1", + hasAvatar: false, + updatedAt: 1, + }, + }; + return { client, handler, socket }; + } + + function row(email: string) { + return listSystemPresence().find((entry) => entry.user?.email === email); + } + + it("retains the oldest online interval across overlapping sockets but not a full reconnect", async () => { + const first = await connect("first@timing.test"); + const started = Date.now(); + expect(first.handler.setClient(first.client)).toBe(true); + expect(row("first@timing.test")).toMatchObject({ onlineSince: started }); + expect(row("first@timing.test")?.lastActivityAt).toBeUndefined(); + + vi.setSystemTime(started + 1_000); + const second = await connect("second@timing.test"); + expect(second.handler.setClient(second.client)).toBe(true); + expect(recordClientPresenceActivity(clients, second.client)).toBe(true); + first.socket.readyState = 3; + first.socket.emit("close", 1000, Buffer.alloc(0)); + + vi.setSystemTime(started + 2_000); + const third = await connect("third@timing.test"); + expect(third.handler.setClient(third.client)).toBe(true); + expect(row("third@timing.test")).toMatchObject({ + onlineSince: started, + lastActivityAt: started + 1_000, + }); + for (const connection of [second, third]) { + connection.socket.readyState = 3; + connection.socket.emit("close", 1000, Buffer.alloc(0)); + } + vi.setSystemTime(started + 3_000); + const fresh = await connect("fresh@timing.test"); + expect(fresh.handler.setClient(fresh.client)).toBe(true); + expect(row("fresh@timing.test")).toMatchObject({ onlineSince: started + 3_000 }); + expect(row("fresh@timing.test")?.lastActivityAt).toBeUndefined(); + }); + + it("keeps heartbeat freshness and cache eviction independent of person timing", async () => { + const first = await connect("heartbeat@timing.test", "heartbeat-person"); + const started = Date.now(); + first.handler.setClient(first.client); + recordClientPresenceActivity(clients, first.client); + vi.setSystemTime(started + 10_000); + first.socket.emit("pong"); + updateSystemPresence({ + instanceId: first.client.presenceKey, + text: "heartbeat", + lastInputSeconds: 0, + }); + expect(row("heartbeat@timing.test")).toMatchObject({ + ts: started + 10_000, + onlineSince: started, + lastActivityAt: started, + }); + + vi.setSystemTime(started + 20_000); + for (let index = 0; index < 201; index++) { + upsertPresence(`timing-eviction-${index}`, { text: "cache pressure" }); + } + expect(row("heartbeat@timing.test")).toBeUndefined(); + const overlap = await connect("eviction@timing.test", "heartbeat-person"); + overlap.handler.setClient(overlap.client); + expect(row("eviction@timing.test")).toMatchObject({ + onlineSince: started, + lastActivityAt: started, + }); + + vi.setSystemTime(started + 400_000); + expect(row("eviction@timing.test")).toBeUndefined(); + expect(recordClientPresenceActivity(clients, overlap.client)).toBe(true); + expect(row("eviction@timing.test")).toMatchObject({ + onlineSince: started, + lastActivityAt: started + 400_000, + }); + }); + + it("keeps delayed identity timing on its accepted socket without reviving closed clients", async () => { + const delayed = await connect("pending@timing.test", "delayed-person"); + const profile = delayed.client.authenticatedUserProfile; + delete delayed.client.authenticatedUserProfile; + delayed.client.authenticatedGitHubIdentitySync = async () => ({ + profileId: "delayed-person", + updatedAt: 1, + }); + const started = Date.now(); + delayed.handler.setClient(delayed.client); + expect(row("pending@timing.test")).toBeUndefined(); + expect(recordClientPresenceActivity(clients, delayed.client)).toBe(false); + vi.setSystemTime(started + 1_000); + delayed.client.authenticatedUserProfile = profile; + refreshClientPresence(clients, delayed.client); + expect(row("pending@timing.test")).toMatchObject({ onlineSince: started }); + delayed.socket.readyState = 3; + delayed.socket.emit("close", 1000, Buffer.alloc(0)); + vi.setSystemTime(started + 400_000); + expect(row("pending@timing.test")).toBeUndefined(); + expect(refreshClientPresence(clients, delayed.client)).toBe(false); + expect(recordClientPresenceActivity(clients, delayed.client)).toBe(false); + expect(row("pending@timing.test")).toBeUndefined(); + }); + + it("rejects copied, invalidated, closing, and unregistered clients without changing activity", async () => { + const live = await connect("exact@timing.test", "exact-person"); + live.handler.setClient(live.client); + expect(recordClientPresenceActivity(clients, { ...live.client })).toBe(false); + live.client.invalidated = true; + expect(recordClientPresenceActivity(clients, live.client)).toBe(false); + live.client.invalidated = false; + live.socket.readyState = 2; + expect(recordClientPresenceActivity(clients, live.client)).toBe(false); + expect(row("exact@timing.test")?.lastActivityAt).toBeUndefined(); + const rejected = await connect("rejected@timing.test"); + rejected.socket.emit("close", 1000, Buffer.alloc(0)); + expect(rejected.handler.setClient(rejected.client)).toBe(false); + expect(rejected.client.personPresence).toBeUndefined(); + expect(row("rejected@timing.test")).toBeUndefined(); + }); + + it.each(["ephemeral", "unidentified", "node"])( + "does not create person timing for %s clients", + async (kind) => { + const connection = await connect(`${kind}@timing.test`); + if (kind === "ephemeral") { + delete connection.client.presenceKey; + } else if (kind === "node") { + connection.client.connect.role = "node"; + } else { + delete connection.client.authenticatedUserId; + delete connection.client.authenticatedUserProfile; + } + expect(connection.handler.setClient(connection.client)).toBe(true); + expect(connection.client.personPresence).toBeUndefined(); + expect(recordClientPresenceActivity(clients, connection.client)).toBe(false); + }, + ); + + it("does not refresh a node's heartbeat or timing when its person is active", async () => { + const node = await connect("node@timing.test", "node-person"); + node.client.connect.role = "node"; + node.handler.setClient(node.client); + const nodeHeartbeat = Date.now(); + upsertPresence(node.client.presenceKey!, { + user: { id: "node-person", email: "node@timing.test" }, + }); + vi.setSystemTime(nodeHeartbeat + 1_000); + const person = await connect("person@timing.test", "node-person"); + person.handler.setClient(person.client); + recordClientPresenceActivity(clients, person.client); + expect(row("node@timing.test")).toMatchObject({ ts: nodeHeartbeat }); + expect(row("node@timing.test")?.onlineSince).toBeUndefined(); + expect(row("node@timing.test")?.lastActivityAt).toBeUndefined(); + }); +}); diff --git a/src/gateway/server/client-presence.ts b/src/gateway/server/client-presence.ts new file mode 100644 index 000000000000..bf21798c9284 --- /dev/null +++ b/src/gateway/server/client-presence.ts @@ -0,0 +1,80 @@ +import { upsertPresence } from "../../infra/system-presence.js"; +import { buildAuthenticatedPresenceUser } from "../authenticated-presence-user.js"; +import { WEBSOCKET_OPEN_READY_STATE } from "../server-constants.js"; +import type { GatewayClient } from "../server-methods/types.js"; +import type { GatewayWsClient } from "./ws-types.js"; + +function isLiveClient(client: GatewayWsClient): boolean { + return !client.invalidated && client.socket.readyState === WEBSOCKET_OPEN_READY_STATE; +} + +function presenceIdentity(client: GatewayWsClient): string | undefined { + return ( + client.authenticatedUserProfile?.profileId ?? + (client.authenticatedGitHubIdentitySync ? undefined : client.authenticatedUserId) + ); +} + +/** Reconciles canonical identity and timing using only currently registered sockets. */ +export function refreshClientPresence( + clients: ReadonlySet, + client: GatewayWsClient, +): boolean { + if (!clients.has(client) || !isLiveClient(client) || !client.presenceKey) { + return false; + } + const identity = presenceIdentity(client); + if (!identity || !client.authenticatedUserId) { + return false; + } + const peers = [...clients].filter( + (peer) => + isLiveClient(peer) && + peer.presenceKey && + presenceIdentity(peer) === identity && + (peer === client || (client.personPresence && peer.personPresence)), + ); + const timing = client.personPresence; + for (const peer of peers) { + if (timing && peer.personPresence) { + timing.onlineSince = Math.min(timing.onlineSince, peer.personPresence.onlineSince); + const activity = peer.personPresence.lastActivityAt; + if (activity !== undefined) { + timing.lastActivityAt = Math.max(timing.lastActivityAt ?? activity, activity); + } + } + } + for (const peer of peers) { + // Nodes retain their device lifecycle. Only person sockets share the interval, + // including its original start after the oldest socket closes or a profile merges. + if (timing && peer.personPresence) { + peer.personPresence = timing; + } + upsertPresence(peer.presenceKey!, { + user: buildAuthenticatedPresenceUser(peer), + ...peer.personPresence, + }); + } + return true; +} + +/** Records accepted human activity; copies and clients closed during admission cannot write. */ +export function recordClientPresenceActivity( + clients: ReadonlySet, + client: GatewayClient | null, +): boolean { + for (const live of clients) { + if ( + live !== client || + !isLiveClient(live) || + !live.presenceKey || + !live.personPresence || + !presenceIdentity(live) + ) { + continue; + } + live.personPresence.lastActivityAt = Date.now(); + return refreshClientPresence(clients, live); + } + return false; +} diff --git a/src/gateway/server/ws-connection.startup.test.ts b/src/gateway/server/ws-connection.startup.test.ts index d6171194ffa7..037c1953ba69 100644 --- a/src/gateway/server/ws-connection.startup.test.ts +++ b/src/gateway/server/ws-connection.startup.test.ts @@ -130,8 +130,6 @@ async function attachStartupNodeConnect(params: { const requestContext = { ...createGatewayWsTestRequestContext(), nodeRegistry, - broadcast: vi.fn(), - nodeUnsubscribeAll: vi.fn(), }; const pendingSetup = vi.fn(params.isPendingWorkerNodeSetup); attachGatewayWsForTest({ diff --git a/src/gateway/server/ws-connection.test-helpers.ts b/src/gateway/server/ws-connection.test-helpers.ts index 758220416354..2ca91b172997 100644 --- a/src/gateway/server/ws-connection.test-helpers.ts +++ b/src/gateway/server/ws-connection.test-helpers.ts @@ -51,6 +51,9 @@ export function createGatewayWsTestRequestContext( unsubscribeAllSessionEvents: vi.fn(), nodeRegistry: overrides.nodeRegistry ?? { unregister: vi.fn() }, nodeUnsubscribeAll: vi.fn(), + broadcast: vi.fn(), + incrementPresenceVersion: vi.fn(() => 1), + getHealthVersion: vi.fn(() => 1), }; } diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index a4415e8bf672..2521f52f2753 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -35,6 +35,7 @@ import { } from "../stale-install.js"; import { cleanupTalkConnection } from "../talk-session-registry.js"; import { formatForLog, logWs } from "../ws-log.js"; +import { refreshClientPresence } from "./client-presence.js"; import { getHealthVersion, incrementPresenceVersion } from "./health-state.js"; import type { PreauthConnectionBudget } from "./preauth-connection-budget.js"; import { broadcastPresenceSnapshot } from "./presence-events.js"; @@ -512,12 +513,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti context.terminalSessions?.handleDisconnect(connId); let currentDisconnectedNodeId: string | null = null; let disconnectedNodeHistory: - | { - nodeId: string; - connectedAtMs: number; - disconnectedAtMs: number; - pairingGeneration: string; - } + | Parameters[0] | undefined; if (client?.connect?.role === "node") { const nodeId = client.connect.device?.id ?? client.connect.client.id; @@ -527,7 +523,10 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti nodeId: nodeSession.nodeId, connectedAtMs: nodeSession.connectedAtMs, disconnectedAtMs: Date.now(), - pairingGeneration: nodeSession.pairingGeneration, + expectedPairingGeneration: { + nodeId: nodeSession.nodeId, + key: nodeSession.pairingGeneration, + }, }; } // Retire I/O now, but retain revocation until admitted lifecycle work drains. @@ -569,15 +568,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti currentDisconnectedNodeId === disconnectedNodeHistory.nodeId ) { try { - await recordPairedNodeDisconnection({ - nodeId: disconnectedNodeHistory.nodeId, - connectedAtMs: disconnectedNodeHistory.connectedAtMs, - disconnectedAtMs: disconnectedNodeHistory.disconnectedAtMs, - expectedPairingGeneration: { - nodeId: disconnectedNodeHistory.nodeId, - key: disconnectedNodeHistory.pairingGeneration, - }, - }); + await recordPairedNodeDisconnection(disconnectedNodeHistory); } catch (error) { logGateway.warn( `failed to record node disconnect for ${disconnectedNodeHistory.nodeId}: ${formatForLog(error)}`, @@ -628,6 +619,10 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti releasePreauthBudget(); client = next; clients.add(next); + if (next.presenceKey && next.authenticatedUserId && next.connect.role !== "node") { + next.personPresence = { onlineSince: Date.now() }; + refreshClientPresence(clients, next); + } pingTimer = setInterval(() => { // A half-open TCP connection can remain OPEN indefinitely. Terminate // after one missed pong so the normal close handler releases node state. diff --git a/src/gateway/server/ws-connection/connect-session.ts b/src/gateway/server/ws-connection/connect-session.ts index 48a8dca5b277..04e35b5a438d 100644 --- a/src/gateway/server/ws-connection/connect-session.ts +++ b/src/gateway/server/ws-connection/connect-session.ts @@ -50,10 +50,10 @@ import { setClientPluginNodeCapability, type PluginNodeCapabilitySurface, } from "../../plugin-node-capability.js"; -import { MAX_PAYLOAD_BYTES } from "../../server-constants.js"; +import { MAX_PAYLOAD_BYTES, WEBSOCKET_OPEN_READY_STATE } from "../../server-constants.js"; import { formatForLog, logWs } from "../../ws-log.js"; import { truncateCloseReason } from "../close-reason.js"; -import { incrementPresenceVersion } from "../health-state.js"; +import { broadcastPresenceSnapshot } from "../presence-events.js"; import type { GatewayWsClient } from "../ws-types.js"; import { resolveEffectiveConnectionScopes } from "./connect-admission.js"; import { sendGatewayHello } from "./connect-hello.js"; @@ -214,7 +214,7 @@ export async function attachAuthenticatedGatewayConnect( : ensureProfileForEmail(authenticatedUserId); const profileId = "profileId" in profile ? profile.profileId : profile.id; const display = getUserProfileDisplay(profileId); - // User edits become visible after reconnect; detached provider-avatar adoption refreshes below. + // The live profile callback refreshes edits and detached provider-avatar adoption. authenticatedUserProfile = { profileId: display.id, displayName: display.displayName, @@ -434,6 +434,14 @@ export async function attachAuthenticatedGatewayConnect( }; attachGatewayLocalUserIngress(nextClient, localUserIngress); const attachAuthenticatedProfile = (profileId: string, updatedAt: number) => { + if ( + isClosed() || + context.handler.getClient() !== nextClient || + nextClient.invalidated || + socket.readyState !== WEBSOCKET_OPEN_READY_STATE + ) { + return; + } const display = getUserProfileDisplay(profileId); const profile = { profileId: display.id, @@ -599,7 +607,9 @@ export async function attachAuthenticatedGatewayConnect( ...(authenticatedPresenceUser ? { user: authenticatedPresenceUser } : {}), reason: "connect", }); - incrementPresenceVersion(); + // Publish the completed row before hello snapshots it; existing readers do + // not receive this connection's hello and must not wait for later activity. + broadcastPresenceSnapshot(buildRequestContext()); } if (admittedNodePairing) { const pairingGeneration = admittedNodePairing.generation?.key; diff --git a/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts b/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts index 8694fde66a06..4afe38640bcd 100644 --- a/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts +++ b/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts @@ -40,7 +40,10 @@ vi.mock("../../../config/config.js", () => ({ loadConfig: () => gatewayConfig, })); vi.mock("../../../config/io.js", () => ({ getRuntimeConfig: () => gatewayConfig })); -vi.mock("../../../infra/system-presence.js", () => ({ upsertPresence: upsertPresenceMock })); +vi.mock("../../../infra/system-presence.js", () => ({ + upsertPresence: upsertPresenceMock, + listSystemPresence: vi.fn(() => []), +})); vi.mock("../../../state/user-profiles.js", () => ({ adoptTailscaleProfileAvatar: vi.fn(), ensureProfileForEmail: vi.fn(async () => ({ @@ -77,7 +80,6 @@ vi.mock("../health-state.js", () => ({ })), getHealthCache: vi.fn(() => null), getHealthVersion: vi.fn(() => 1), - incrementPresenceVersion: incrementPresenceVersionMock, })); vi.mock("../../../version.js", async (importOriginal) => { const actual = await importOriginal(); @@ -211,7 +213,12 @@ describe("Control UI build admission over WebSocket", () => { gatewayMethods: [], events: [], extraHandlers: {}, - buildRequestContext: () => ({ broadcast: vi.fn() }) as unknown as GatewayRequestContext, + buildRequestContext: () => + ({ + broadcast: vi.fn(), + incrementPresenceVersion: incrementPresenceVersionMock, + getHealthVersion: () => 1, + }) as unknown as GatewayRequestContext, nodeLifecycleDispatch: new GatewayNodeLifecycleDispatchTracker(), refreshHealthSnapshot: vi.fn(), send, diff --git a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts index 88b458b3dcda..7d8ca20c3b41 100644 --- a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts +++ b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts @@ -127,6 +127,7 @@ vi.mock("../../../config/io.js", () => ({ })); vi.mock("../../../infra/system-presence.js", () => ({ upsertPresence: upsertPresenceMock, + listSystemPresence: vi.fn(() => []), })); vi.mock("../../server-methods.js", () => ({ @@ -137,7 +138,6 @@ vi.mock("../health-state.js", () => ({ buildGatewaySnapshot: buildGatewaySnapshotMock, getHealthCache: getHealthCacheMock, getHealthVersion: getHealthVersionMock, - incrementPresenceVersion: incrementPresenceVersionMock, })); import { attachGatewayWsMessageHandler } from "./message-handler.js"; @@ -288,6 +288,7 @@ function attachGatewayHarness(options: { }); let onMessage: ((data: string) => void) | undefined; const socket = { + readyState: 1, _receiver: {}, send: socketSend, on: vi.fn((event: string, handler: (data: string) => void) => { @@ -360,7 +361,13 @@ function attachGatewayHarness(options: { gatewayMethods: [], events: [], extraHandlers: {}, - buildRequestContext: () => ({ refreshConnectedUserProfile }) as never, + buildRequestContext: () => + ({ + refreshConnectedUserProfile, + broadcast: vi.fn(), + incrementPresenceVersion: incrementPresenceVersionMock, + getHealthVersion: getHealthVersionMock, + }) as never, nodeLifecycleDispatch: new GatewayNodeLifecycleDispatchTracker(), refreshHealthSnapshot: options.refreshHealthSnapshot ?? vi.fn(async () => createHealthSummary()), @@ -974,88 +981,102 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { }); }); - it("completes GitHub-authenticated login before deferred identity sync", async () => { - await withOpenClawTestState({ label: "gateway-github-profile-deferred" }, async () => { - const canonical = ensureProfileForEmail("canonical@example.test"); - let finishSync: (() => void) | undefined; - const sync = vi.fn( - async () => - await new Promise<{ profileId: string; updatedAt: number }>((resolve) => { - finishSync = () => resolve({ profileId: canonical.id, updatedAt: canonical.updatedAt }); - }), - ); - createAuthenticatedGitHubIdentitySyncMock.mockReturnValueOnce(sync); - resolveConnectAuthStateMock.mockResolvedValueOnce({ - authResult: { - ok: true, - method: "tailscale", - user: "ada@github", - tailscaleIdentity: { login: "ada@github", name: "Ada Lovelace" }, - }, - authOk: true, - authMethod: "tailscale", - sharedAuthOk: true, - }); - const harness = attachGatewayHarness({ - connId: "conn-github-identity-detached", - connectNonce: "nonce-github-identity-detached", - }); - - harness.sendConnect("connect-github-identity-detached", { - minProtocol: PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: { - id: "test", - version: "dev", - platform: "test", - mode: "test", - }, - role: "operator", - caps: [], - }); - - await waitForFast(() => { - expect(harness.socketSend).toHaveBeenCalled(); - expect(harness.client).toMatchObject({ - authenticatedUserId: "ada@github", - authenticatedGitHubIdentitySync: expect.any(Function), - }); - expect(harness.client).not.toHaveProperty("authenticatedUserProfile"); - expect(localUserIngressFor(harness.client)).toMatchObject({ - facts: { invoker: { state: "unknown" } }, - }); - expect(createAuthenticatedGitHubIdentitySyncMock).toHaveBeenCalledWith( - expect.objectContaining({ - authResult: expect.objectContaining({ method: "tailscale", user: "ada@github" }), - }), + it.each([false, true])( + "completes deferred identity sync only while its socket is live (closed=%s)", + async (closedBeforeSync) => { + await withOpenClawTestState({ label: "gateway-github-profile-deferred" }, async () => { + const canonical = ensureProfileForEmail("canonical@example.test"); + let finishSync: (() => void) | undefined; + const sync = vi.fn( + async () => + await new Promise<{ profileId: string; updatedAt: number }>((resolve) => { + finishSync = () => + resolve({ profileId: canonical.id, updatedAt: canonical.updatedAt }); + }), ); - expect(sync).toHaveBeenCalledOnce(); - }); - const initialPresence = upsertPresenceMock.mock.calls.find( - ([key]) => key === "conn-github-identity-detached", - )?.[1]; - expect(initialPresence).not.toHaveProperty("user"); - expect(harness.socketSend.mock.invocationCallOrder[0]).toBeLessThan( - sync.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, - ); - expect(finishSync).toBeTypeOf("function"); - finishSync?.(); - - await waitForFast(() => { - expect(harness.client).toMatchObject({ - authenticatedUserProfile: { profileId: canonical.id }, - }); - expect(localUserIngressFor(harness.client)).toMatchObject({ - facts: { - invoker: { state: "present", kind: "person", rawPrincipalRef: canonical.id }, + createAuthenticatedGitHubIdentitySyncMock.mockReturnValueOnce(sync); + resolveConnectAuthStateMock.mockResolvedValueOnce({ + authResult: { + ok: true, + method: "tailscale", + user: "ada@github", + tailscaleIdentity: { login: "ada@github", name: "Ada Lovelace" }, }, + authOk: true, + authMethod: "tailscale", + sharedAuthOk: true, }); - expect(harness.refreshConnectedUserProfile).toHaveBeenCalledWith( - expect.objectContaining({ id: canonical.id }), + let closed = false; + const harness = attachGatewayHarness({ + connId: "conn-github-identity-detached", + connectNonce: "nonce-github-identity-detached", + isClosed: () => closed, + }); + + harness.sendConnect("connect-github-identity-detached", { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "test", + version: "dev", + platform: "test", + mode: "test", + }, + role: "operator", + caps: [], + }); + + await waitForFast(() => { + expect(harness.socketSend).toHaveBeenCalled(); + expect(harness.client).toMatchObject({ + authenticatedUserId: "ada@github", + authenticatedGitHubIdentitySync: expect.any(Function), + }); + expect(harness.client).not.toHaveProperty("authenticatedUserProfile"); + expect(localUserIngressFor(harness.client)).toMatchObject({ + facts: { invoker: { state: "unknown" } }, + }); + expect(createAuthenticatedGitHubIdentitySyncMock).toHaveBeenCalledWith( + expect.objectContaining({ + authResult: expect.objectContaining({ method: "tailscale", user: "ada@github" }), + }), + ); + expect(sync).toHaveBeenCalledOnce(); + }); + const initialPresence = upsertPresenceMock.mock.calls.find( + ([key]) => key === "conn-github-identity-detached", + )?.[1]; + expect(initialPresence).not.toHaveProperty("user"); + expect(harness.socketSend.mock.invocationCallOrder[0]).toBeLessThan( + sync.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, ); + expect(finishSync).toBeTypeOf("function"); + closed = closedBeforeSync; + finishSync?.(); + + if (closedBeforeSync) { + await vi.dynamicImportSettled(); + expect(harness.client).not.toHaveProperty("authenticatedUserProfile"); + expect(harness.refreshConnectedUserProfile).not.toHaveBeenCalled(); + return; + } + + await waitForFast(() => { + expect(harness.client).toMatchObject({ + authenticatedUserProfile: { profileId: canonical.id }, + }); + expect(localUserIngressFor(harness.client)).toMatchObject({ + facts: { + invoker: { state: "present", kind: "person", rawPrincipalRef: canonical.id }, + }, + }); + expect(harness.refreshConnectedUserProfile).toHaveBeenCalledWith( + expect.objectContaining({ id: canonical.id }), + ); + }); }); - }); - }); + }, + ); it("resolves a GitHub-backed role before registering the connection or sending hello", async () => { await withOpenClawTestState({ label: "gateway-github-role-before-hello" }, async () => { diff --git a/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts b/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts index 9a6641d24ee7..4a14c66e02b7 100644 --- a/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts +++ b/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts @@ -12,8 +12,7 @@ import { import type { GatewayRequestContext } from "../../server-methods/types.js"; import { GatewayNodeLifecycleDispatchTracker } from "./node-lifecycle-dispatch.js"; -const { incrementPresenceVersionMock, loadConfigMock, upsertPresenceMock } = vi.hoisted(() => ({ - incrementPresenceVersionMock: vi.fn(() => 2), +const { loadConfigMock, upsertPresenceMock } = vi.hoisted(() => ({ loadConfigMock: vi.fn(() => ({ gateway: { auth: { mode: "none" } } })), upsertPresenceMock: vi.fn(), })); @@ -27,6 +26,7 @@ vi.mock("../../../config/io.js", () => ({ })); vi.mock("../../../infra/system-presence.js", () => ({ upsertPresence: upsertPresenceMock, + listSystemPresence: vi.fn(() => []), })); vi.mock("../health-state.js", () => ({ buildGatewaySnapshot: vi.fn(() => ({ @@ -43,7 +43,6 @@ vi.mock("../health-state.js", () => ({ })), getHealthCache: vi.fn(() => null), getHealthVersion: vi.fn(() => 1), - incrementPresenceVersion: incrementPresenceVersionMock, })); import { attachGatewayWsMessageHandler } from "./message-handler.js"; @@ -111,6 +110,7 @@ function attachHarness(params: { deferSocketSend?: boolean; startupPending?: boo gatewayMethods: [], events: [], extraHandlers: {}, + // Backend admission cases never publish presence; hello is mocked above. buildRequestContext: () => ({}) as GatewayRequestContext, nodeLifecycleDispatch: new GatewayNodeLifecycleDispatchTracker(), refreshHealthSnapshot: vi.fn(async () => ({}) as never), @@ -266,7 +266,6 @@ describe("WebSocket connect suspension admission", () => { expect(harness.client).toBeNull(); expect(harness.setClient).not.toHaveBeenCalled(); expect(upsertPresenceMock).not.toHaveBeenCalled(); - expect(incrementPresenceVersionMock).not.toHaveBeenCalled(); await vi.waitFor(() => { expect(harness.close).toHaveBeenCalledWith(1013, "gateway suspension in progress"); }); diff --git a/src/gateway/server/ws-types.ts b/src/gateway/server/ws-types.ts index f1098172a110..ac2586c3eb4f 100644 --- a/src/gateway/server/ws-types.ts +++ b/src/gateway/server/ws-types.ts @@ -34,6 +34,8 @@ export type GatewayWsClient = PluginNodeCapabilityClient & { usesSharedGatewayAuth: boolean; sharedGatewaySessionGeneration?: string; presenceKey?: string; + /** Shared by overlapping identified person sockets; never owned by the presence TTL cache. */ + personPresence?: { onlineSince: number; lastActivityAt?: number }; authenticatedUserId?: string; /** Verified Tailscale provider identity; generic proxy identities must not infer this. */ authenticatedUserIsTailscaleProvider?: boolean; diff --git a/src/gateway/session-viewer-presence.test.ts b/src/gateway/session-viewer-presence.test.ts index 68704c82843a..f128cd1957eb 100644 --- a/src/gateway/session-viewer-presence.test.ts +++ b/src/gateway/session-viewer-presence.test.ts @@ -1,55 +1,101 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { listSystemPresence } from "../infra/system-presence.js"; +import { GatewayClientRegistry } from "./server/client-registry.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; import { createSessionViewerPresenceDeclarations } from "./session-viewer-presence.js"; +function createDeclarations() { + const client: GatewayWsClient = { + connId: "conn-a", + presenceKey: "viewer-timing", + usesSharedGatewayAuth: false, + socket: { readyState: 1 } as GatewayWsClient["socket"], + connect: { + minProtocol: 1, + maxProtocol: 1, + role: "operator", + client: { id: "openclaw-control-ui", version: "test", platform: "test", mode: "webchat" }, + }, + authenticatedUserId: "viewer@timing.test", + personPresence: { onlineSince: Date.now() - 1_000 }, + }; + const clients = new GatewayClientRegistry([client]); + const broadcast = vi.fn(); + const incrementPresenceVersion = vi.fn(() => 2); + const declarations = createSessionViewerPresenceDeclarations({ + clients, + broadcast, + incrementPresenceVersion, + getHealthVersion: () => 1, + }); + const row = () => listSystemPresence().find((entry) => entry.user?.id === "viewer@timing.test"); + return { declarations, client, clients, broadcast, incrementPresenceVersion, row }; +} + describe("session viewer presence declarations", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2041-01-01T00:00:00Z")); + }); + afterEach(() => { + vi.setSystemTime(Date.now() + 300_001); + listSystemPresence(); + vi.useRealTimers(); + }); + it("replaces rather than accumulates connection session keys", () => { - const onReplace = vi.fn(); - const declarations = createSessionViewerPresenceDeclarations({ onReplace }); + const { declarations, broadcast, row } = createDeclarations(); expect(declarations.replace("conn-a", [" beta ", "alpha", "beta"])).toEqual(["alpha", "beta"]); + expect(row()?.watchedSessions).toEqual(["alpha", "beta"]); + vi.setSystemTime(Date.now() + 1_000); expect(declarations.replace("conn-a", ["gamma"])).toEqual(["gamma"]); - expect(onReplace.mock.calls).toEqual([ - ["conn-a", ["alpha", "beta"]], - ["conn-a", ["gamma"]], - ]); + expect(row()).toMatchObject({ watchedSessions: ["gamma"], lastActivityAt: Date.now() }); + expect(broadcast).toHaveBeenCalledTimes(2); }); it("publishes an empty declaration and forgets state on disconnect", () => { - const onReplace = vi.fn(); - const declarations = createSessionViewerPresenceDeclarations({ onReplace }); + const { declarations, broadcast, row } = createDeclarations(); declarations.replace("conn-a", ["alpha"]); + const activity = row()?.lastActivityAt; + vi.setSystemTime(Date.now() + 1_000); declarations.replace("conn-a", []); + expect(row()?.watchedSessions).toBeUndefined(); + expect(row()?.lastActivityAt).toBe(activity); declarations.replace("conn-a", ["beta"]); + const nextActivity = row()?.lastActivityAt; + vi.setSystemTime(Date.now() + 1_000); declarations.unsubscribe("conn-a"); + expect(row()?.lastActivityAt).toBe(nextActivity); declarations.replace("conn-a", ["beta"]); - - expect(onReplace.mock.calls).toEqual([ - ["conn-a", ["alpha"]], - ["conn-a", []], - ["conn-a", ["beta"]], - ["conn-a", ["beta"]], - ]); + expect(row()?.lastActivityAt).toBe(Date.now()); + expect(broadcast).toHaveBeenCalledTimes(4); }); it("does not republish an unchanged set", () => { - const onReplace = vi.fn(); - const declarations = createSessionViewerPresenceDeclarations({ onReplace }); + const { declarations, broadcast, incrementPresenceVersion, row } = createDeclarations(); declarations.replace("conn-a", ["beta", "alpha"]); + const activity = row()?.lastActivityAt; + vi.setSystemTime(Date.now() + 1_000); declarations.replace("conn-a", ["alpha", "beta"]); - expect(onReplace).toHaveBeenCalledOnce(); + expect(row()?.lastActivityAt).toBe(activity); + expect(broadcast).toHaveBeenCalledOnce(); + expect(incrementPresenceVersion).toHaveBeenCalledOnce(); }); - it("rejects declarations from inactive connections", () => { - const onReplace = vi.fn(); - const declarations = createSessionViewerPresenceDeclarations({ - onReplace, - isConnectionActive: () => false, - }); - - expect(declarations.replace("conn-closed", ["alpha"])).toEqual([]); - expect(onReplace).not.toHaveBeenCalled(); + it("rejects declarations from inactive connections and after stop", () => { + const { declarations, client, clients, broadcast } = createDeclarations(); + client.invalidated = true; + expect(declarations.replace("conn-a", ["alpha"])).toEqual([]); + client.invalidated = false; + clients.delete(client); + expect(declarations.replace("conn-a", ["alpha"])).toEqual([]); + clients.add(client); + declarations.stop(); + expect(declarations.replace("conn-a", ["alpha"])).toEqual([]); + expect(broadcast).not.toHaveBeenCalled(); }); }); diff --git a/src/gateway/session-viewer-presence.ts b/src/gateway/session-viewer-presence.ts index 924271419b63..62f9e16b7bbe 100644 --- a/src/gateway/session-viewer-presence.ts +++ b/src/gateway/session-viewer-presence.ts @@ -1,9 +1,13 @@ // Per-connection viewer presence declarations. Message subscriptions are transport state, // while this replace-set records only the sessions a client is actually rendering. +import { upsertPresence } from "../infra/system-presence.js"; +import { WEBSOCKET_OPEN_READY_STATE } from "./server-constants.js"; +import { recordClientPresenceActivity } from "./server/client-presence.js"; +import type { GatewayClientRegistry } from "./server/client-registry.js"; +import { broadcastPresenceSnapshot } from "./server/presence-events.js"; -type SessionViewerPresenceDeclarationsDeps = { - onReplace: (connId: string, sessionKeys: readonly string[]) => void; - isConnectionActive?: (connId: string) => boolean; +type SessionViewerPresenceDeclarationsDeps = Parameters[0] & { + clients: GatewayClientRegistry; }; type SessionViewerPresenceDeclarations = { @@ -36,7 +40,8 @@ export function createSessionViewerPresenceDeclarations( return []; } const normalizedConnId = connId.trim(); - if (!normalizedConnId || deps.isConnectionActive?.(normalizedConnId) === false) { + const client = deps.clients.getByConnectionId(normalizedConnId); + if (!client || client.invalidated || client.socket.readyState !== WEBSOCKET_OPEN_READY_STATE) { return []; } const next = normalizedSessionKeys(sessionKeys); @@ -49,7 +54,15 @@ export function createSessionViewerPresenceDeclarations( } else { declarations.set(normalizedConnId, next); } - deps.onReplace(normalizedConnId, next); + if (client.presenceKey) { + upsertPresence(client.presenceKey, { + watchedSessions: next.length > 0 ? [...next] : undefined, + }); + if (next.length > 0) { + recordClientPresenceActivity(deps.clients, client); + } + broadcastPresenceSnapshot(deps); + } return next; }; diff --git a/src/infra/system-presence.ts b/src/infra/system-presence.ts index 99ee7a94cbc6..a52653134b96 100644 --- a/src/infra/system-presence.ts +++ b/src/infra/system-presence.ts @@ -34,7 +34,11 @@ export type SystemPresence = { avatarUrl?: string; }; watchedSessions?: string[]; + /** Server-owned timing for the person's current continuous live interval. */ + onlineSince?: number; + lastActivityAt?: number; text: string; + /** Heartbeat freshness, independent of person activity and online duration. */ ts: number; }; diff --git a/src/plugins/bundled-capability-runtime.ts b/src/plugins/bundled-capability-runtime.ts index 4f36fa681253..2abfbb4aceb5 100644 --- a/src/plugins/bundled-capability-runtime.ts +++ b/src/plugins/bundled-capability-runtime.ts @@ -6,7 +6,6 @@ import { loadOpenClawPluginsWithInternalOverrides } from "./loader-runtime-load. import type { PluginLoadOptions } from "./loader.js"; import { loadPluginManifestRegistryCore } from "./manifest-registry.js"; import type { PluginRuntime } from "./runtime/types.js"; -import type { PluginSdkResolutionPreference } from "./sdk-alias.js"; const log = createSubsystemLogger("plugins"); @@ -26,13 +25,24 @@ function createCapabilityRegistrationRuntime( }; } -export function loadBundledCapabilityRuntimeRegistry(params: { - pluginIds: readonly string[]; - env?: PluginLoadOptions["env"]; - config?: PluginLoadOptions["config"]; - pluginSdkResolution?: PluginSdkResolutionPreference; - discovery?: PluginDiscoveryResult; -}) { +export function loadBundledCapabilityRuntimeRegistry( + params: Pick< + PluginLoadOptions, + | "env" + | "config" + | "workspaceDir" + | "installRecords" + | "manifestRegistry" + | "activationSourceConfig" + | "autoEnabledReasons" + | "preferBuiltPluginArtifacts" + | "pluginSdkResolution" + > & { + pluginIds: readonly string[]; + discovery?: PluginDiscoveryResult; + }, +) { + const { pluginIds: requestedPluginIds, ...loadOptions } = params; const env = params.env ?? process.env; // Only the speech owner may opt into legacy global-disable compatibility before capture. const config = @@ -42,14 +52,25 @@ export function loadBundledCapabilityRuntimeRegistry(params: { config: params.config, pluginIds: params.pluginIds, }) ?? {}); - const discovery = params.discovery ?? discoverOpenClawPlugins({ env }); + const discovery = params.manifestRegistry + ? undefined + : (params.discovery ?? + discoverOpenClawPlugins({ + env, + workspaceDir: params.workspaceDir, + installRecords: params.installRecords, + })); const pluginIds = new Set(params.pluginIds); - const manifestRegistry = loadPluginManifestRegistryCore({ - config, - env, - candidates: discovery.candidates, - diagnostics: discovery.diagnostics, - }); + const manifestRegistry = + params.manifestRegistry ?? + loadPluginManifestRegistryCore({ + config, + env, + workspaceDir: params.workspaceDir, + installRecords: params.installRecords, + candidates: discovery?.candidates, + diagnostics: discovery?.diagnostics, + }); const scopedManifestRegistry = { plugins: manifestRegistry.plugins.filter( (plugin) => plugin.origin === "bundled" && pluginIds.has(plugin.id), @@ -58,10 +79,10 @@ export function loadBundledCapabilityRuntimeRegistry(params: { }; return loadOpenClawPluginsWithInternalOverrides( { + ...loadOptions, config, env, - onlyPluginIds: [...params.pluginIds], - pluginSdkResolution: params.pluginSdkResolution, + onlyPluginIds: [...requestedPluginIds], cache: false, activate: false, // Channel setup entries cannot register providers; keep their runtime entry in discovery mode. diff --git a/src/plugins/capability-provider-runtime.generation.test.ts b/src/plugins/capability-provider-runtime.generation.test.ts new file mode 100644 index 000000000000..1a92a0d47c04 --- /dev/null +++ b/src/plugins/capability-provider-runtime.generation.test.ts @@ -0,0 +1,306 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { createPluginMetadataSnapshot } from "../config/plugin-auto-enable.test-helpers.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { loadGatewayPlugins } from "../gateway/server-plugins.js"; +import { withEnv } from "../test-utils/env.js"; +import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js"; +import { withBundledPluginEnablementCompat } from "./bundled-compat.js"; +import { + resolvePluginCapabilityProvider, + resolvePluginCapabilityProviders, +} from "./capability-provider-runtime.js"; +import { setCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; +import * as discovery from "./discovery.js"; +import * as installRecords from "./installed-plugin-index-record-reader.js"; +import { loadOpenClawPlugins } from "./loader.js"; +import { + cleanupPluginLoaderFixturesForTest, + EMPTY_PLUGIN_SCHEMA, + makePluginLoaderTempDir, + mkdirSafe, + resetPluginLoaderTestStateForTest, + writePlugin, +} from "./loader.test-fixtures.js"; +import * as manifests from "./manifest-registry.js"; +import { createEmptyPluginRegistry } from "./registry-empty.js"; +import { getActivePluginRegistry, setActivePluginRegistry } from "./runtime.js"; +import { withPluginRuntimeRegistryScope } from "./runtime/gateway-request-scope.js"; +import { + buildPluginRuntimeLoadOptions, + getPluginRuntimeLoadContext, +} from "./runtime/load-context.js"; + +const id = "fixture-speech"; +const log = { info() {}, warn() {}, error() {}, debug() {} }; + +function withSpeechFixture(run: (fixture: ReturnType) => void) { + const fixture = createSpeechFixture(); + return withEnv( + { + OPENCLAW_STATE_DIR: path.join(fixture.root, "state"), + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(fixture.root, "extensions"), + OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + }, + () => run(fixture), + ); +} + +function createSpeechFixture() { + const root = fs.realpathSync(makePluginLoaderTempDir()); + const workspaceDir = path.join(root, "workspace"); + mkdirSafe(workspaceDir); + const body = (label: string) => `let registrations = 0; +export default { id: "${id}", register(api) { + api.registerSpeechProvider({ id: "${id}", label: "${label}:" + ++registrations, + isConfigured: () => false, synthesize: async () => { throw new Error("synthesis is not catalog discovery"); } }); +} };`; + const plugin = writePlugin({ + id, + dir: path.join(root, "extensions", id), + filename: "index.ts", + body: body("source"), + }); + const manifestPath = path.join(plugin.dir, "openclaw.plugin.json"); + fs.writeFileSync( + manifestPath, + JSON.stringify({ id, configSchema: EMPTY_PLUGIN_SCHEMA, contracts: { speechProviders: [id] } }), + ); + fs.writeFileSync( + path.join(plugin.dir, "package.json"), + JSON.stringify({ openclaw: { extensions: ["./index.ts"] } }), + ); + const builtDir = path.join(root, "dist", "extensions", id); + mkdirSafe(builtDir); + fs.writeFileSync(path.join(builtDir, "index.js"), body("built")); + const seed = writePlugin({ + id: "fixture-seed", + dir: path.join(root, "extensions", "fixture-seed"), + filename: "index.cjs", + body: 'module.exports = { id: "fixture-seed", register() {} };', + }); + fs.writeFileSync( + path.join(seed.dir, "package.json"), + JSON.stringify({ openclaw: { extensions: ["./index.cjs"] } }), + ); + const config: OpenClawConfig = { + agents: { defaults: { workspace: workspaceDir } }, + plugins: { enabled: false }, + }; + return { root, workspaceDir, config }; +} + +function publishMetadata(fixture: ReturnType) { + const snapshot = createPluginMetadataSnapshot({ + config: fixture.config, + workspaceDir: fixture.workspaceDir, + manifestRegistry: manifests.loadPluginManifestRegistryCore({ config: fixture.config }), + }); + setCurrentPluginMetadataSnapshot(snapshot, { + config: fixture.config, + workspaceDir: fixture.workspaceDir, + }); + return snapshot; +} + +function loadGatewayGeneration( + fixture: ReturnType, + workspaceDir = fixture.workspaceDir, + pluginIds: string[] = [], + pluginMetadataSnapshot?: ReturnType, +) { + return loadGatewayPlugins({ + cfg: fixture.config, + activationSourceConfig: fixture.config, + autoEnabledReasons: {}, + workspaceDir, + pluginIds, + pluginMetadataSnapshot, + baseMethods: [], + log, + }).pluginRegistry; +} + +const speechProviders = (cfg: OpenClawConfig) => + resolvePluginCapabilityProviders({ key: "speechProviders", cfg }); + +afterEach(() => { + vi.restoreAllMocks(); + resetPluginLoaderTestStateForTest(); +}); +afterAll(cleanupPluginLoaderFixturesForTest); + +describe("capability loading from a Gateway generation", () => { + it("uses built speech on the first disabled-plugin catalog read without rediscovery or re-registration", () => { + withSpeechFixture((fixture) => { + publishMetadata(fixture); + const registry = loadGatewayGeneration(fixture); + const discover = vi.spyOn(discovery, "discoverOpenClawPlugins"); + const readManifests = vi.spyOn(manifests, "loadPluginManifestRegistryCore"); + const readInstalls = vi.spyOn(installRecords, "loadInstalledPluginIndexInstallRecordsSync"); + withPluginRuntimeRegistryScope(registry, () => { + const first = speechProviders(fixture.config); + expect(first.map((provider) => provider.label)).toEqual(["built:1"]); + expect(speechProviders(fixture.config)).toEqual(first); + expect( + resolvePluginCapabilityProvider({ + key: "speechProviders", + providerId: id, + cfg: fixture.config, + }), + ).toBe(first[0]); + }); + expect(discover).not.toHaveBeenCalled(); + expect(readManifests).not.toHaveBeenCalled(); + expect(readInstalls).not.toHaveBeenCalled(); + expect(getActivePluginRegistry()).toBe(registry); + expect(registry.speechProviders).toEqual([]); + }); + }); + + it("extends a populated Gateway registry without loading missing speech from source", () => { + withSpeechFixture((fixture) => { + fixture.config.plugins = { enabled: true, entries: { "fixture-seed": { enabled: true } } }; + const snapshot = publishMetadata(fixture); + const startupSnapshot = createPluginMetadataSnapshot({ + config: fixture.config, + workspaceDir: fixture.workspaceDir, + manifestRegistry: { + plugins: snapshot.plugins.filter((plugin) => plugin.id === "fixture-seed"), + diagnostics: [], + }, + }); + const registry = loadGatewayPlugins({ + cfg: fixture.config, + activationSourceConfig: fixture.config, + autoEnabledReasons: {}, + workspaceDir: fixture.workspaceDir, + baseMethods: [], + log, + pluginLookUpTable: { + ...startupSnapshot, + pluginIds: ["fixture-seed"], + startup: { pluginIds: ["fixture-seed"], channelPluginIds: [] }, + workerProviderIds: [], + metrics: { ...startupSnapshot.metrics, startupPlanMs: 0, startupPluginCount: 1 }, + }, + }).pluginRegistry; + expect(getPluginRuntimeLoadContext(registry)?.metadataSnapshot).toBe(snapshot); + expect(registry.plugins).toContainEqual( + expect.objectContaining({ id: "fixture-seed", status: "loaded" }), + ); + withPluginRuntimeRegistryScope(registry, () => { + expect(speechProviders(fixture.config).map((provider) => provider.label)).toEqual([ + "built:1", + ]); + }); + }); + }); + + it("keeps the request's load context when an unrelated active registry already contains speech", () => { + withSpeechFixture((fixture) => { + publishMetadata(fixture); + const registry = loadGatewayGeneration(fixture); + const other = loadOpenClawPlugins({ + config: { ...fixture.config, plugins: { entries: { [id]: { enabled: true } } } }, + onlyPluginIds: [id], + }); + expect(other.speechProviders[0]?.provider.label).toBe("source:1"); + withPluginRuntimeRegistryScope(registry, () => { + expect(speechProviders(fixture.config).map((provider) => provider.label)).toEqual([ + "built:1", + ]); + }); + expect(getActivePluginRegistry()).toBe(other); + }); + }); + + it("carries the same metadata and artifact facts through bundled capability capture", () => { + withSpeechFixture((fixture) => { + publishMetadata(fixture); + const registry = loadGatewayGeneration(fixture); + const context = getPluginRuntimeLoadContext(registry); + expect(context).toBeDefined(); + const discover = vi.spyOn(discovery, "discoverOpenClawPlugins"); + const readManifests = vi.spyOn(manifests, "loadPluginManifestRegistryCore"); + const readInstalls = vi.spyOn(installRecords, "loadInstalledPluginIndexInstallRecordsSync"); + const captured = loadBundledCapabilityRuntimeRegistry({ + ...buildPluginRuntimeLoadOptions(context!, { + config: withBundledPluginEnablementCompat({ config: fixture.config, pluginIds: [id] }), + }), + pluginIds: [id], + }); + expect(captured.speechProviders.map((entry) => entry.provider.label)).toEqual(["built:1"]); + expect(discover).not.toHaveBeenCalled(); + expect(readManifests).not.toHaveBeenCalled(); + expect(readInstalls).not.toHaveBeenCalled(); + expect(getActivePluginRegistry()).toBe(registry); + }); + }); + + it("keeps standalone source loading even when a complete metadata snapshot exists", () => { + withSpeechFixture((fixture) => { + publishMetadata(fixture); + setActivePluginRegistry(createEmptyPluginRegistry()); + expect(speechProviders(fixture.config).map((provider) => provider.label)).toEqual([ + "source:1", + ]); + }); + }); + + it("does not borrow artifact preference from a Gateway with a different workspace", () => { + withSpeechFixture((fixture) => { + publishMetadata(fixture); + const registry = loadGatewayGeneration(fixture, path.join(fixture.root, "other-workspace")); + withPluginRuntimeRegistryScope(registry, () => { + expect(speechProviders(fixture.config).map((provider) => provider.label)).toEqual([ + "source:1", + ]); + }); + }); + }); + + it("does not borrow a replaced generation through a retained request registry", () => { + withSpeechFixture((fixture) => { + publishMetadata(fixture); + const previous = loadGatewayGeneration(fixture); + const nextSnapshot = createPluginMetadataSnapshot({ + config: fixture.config, + workspaceDir: fixture.workspaceDir, + manifestRegistry: manifests.loadPluginManifestRegistryCore({ config: fixture.config }), + }); + // Reload prepares the replacement before publishing its metadata generation. + const current = loadGatewayGeneration(fixture, fixture.workspaceDir, [], nextSnapshot); + setCurrentPluginMetadataSnapshot(nextSnapshot, { + config: fixture.config, + workspaceDir: fixture.workspaceDir, + }); + withPluginRuntimeRegistryScope(previous, () => { + expect(speechProviders(fixture.config).map((provider) => provider.label)).toEqual([ + "source:1", + ]); + }); + withPluginRuntimeRegistryScope(current, () => { + expect(speechProviders(fixture.config).map((provider) => provider.label)).toEqual([ + "built:1", + ]); + }); + }); + }); + + it.each([{ deny: [id] }, { entries: { [id]: { enabled: false } } }])( + "preserves explicit speech owner denial: %j", + (policy) => { + withSpeechFixture((fixture) => { + fixture.config.plugins = { enabled: false, ...policy }; + publishMetadata(fixture); + const registry = loadGatewayGeneration(fixture); + withPluginRuntimeRegistryScope(registry, () => { + expect(speechProviders(fixture.config)).toEqual([]); + }); + }); + }, + ); +}); diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index 778a602518c9..53cff6974e0e 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -1374,9 +1374,9 @@ describe("resolvePluginCapabilityProviders", () => { expectActiveRegistryLookup(["google"]); expect(mocks.loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledWith({ pluginIds: ["google"], - env: process.env, + onlyPluginIds: ["google"], + activate: false, config: { tts: { provider: "google" } }, - pluginSdkResolution: undefined, }); }); @@ -1406,9 +1406,9 @@ describe("resolvePluginCapabilityProviders", () => { expectResolvedCapabilityProviderIds(providers, ["openai", "google"]); expect(mocks.loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledWith({ pluginIds: ["google"], - env: process.env, + onlyPluginIds: ["google"], + activate: false, config: { tts: { provider: "google" } }, - pluginSdkResolution: undefined, }); }); diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index b056b5085c28..7601a47bac75 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -9,6 +9,7 @@ import { } from "./active-runtime-registry.js"; import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js"; import { withBundledPluginEnablementCompat } from "./bundled-compat.js"; +import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; import { resolveRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js"; import { hasManifestContractValue, @@ -19,6 +20,12 @@ import { import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js"; import { normalizeCapabilityProviderId } from "./provider-registry-shared.js"; import type { PluginRegistry } from "./registry-types.js"; +import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js"; +import { + buildPluginRuntimeLoadOptions, + getPluginRuntimeLoadContext, + type PluginRuntimeLoadContext, +} from "./runtime/load-context.js"; type CapabilityProviderRegistryKey = | "embeddingProviders" @@ -105,17 +112,40 @@ function resolveCapabilityPluginIds(params: { function createCapabilityProviderLoadOptions(params: { cfg?: OpenClawConfig; resolution: CapabilityPluginResolution; + loadContext?: PluginRuntimeLoadContext; }): PluginLoadOptions { const pluginIds = params.resolution.bundledCompatPluginIds; const config = withBundledPluginEnablementCompat({ config: params.cfg, pluginIds, }); - return { + const overrides: PluginLoadOptions = { ...(config === undefined ? {} : { config }), onlyPluginIds: params.resolution.runtimePluginIds, activate: false, }; + return params.loadContext + ? buildPluginRuntimeLoadOptions(params.loadContext, overrides) + : overrides; +} + +function resolveCapabilityLoadContext( + registry: PluginRegistry | undefined, + cfg: OpenClawConfig | undefined, +): PluginRuntimeLoadContext | undefined { + const context = getPluginRuntimeLoadContext(registry); + if (!context?.metadataSnapshot || context.env !== process.env) { + return undefined; + } + // Validate the caller's original policy before speech compatibility derives an enabled config. + // A retained request must not borrow facts from a replaced or differently scoped generation. + return getCurrentPluginMetadataSnapshot({ + config: cfg, + workspaceDir: context.workspaceDir, + ...(cfg === undefined ? { requireDefaultDiscoveryContext: true } : {}), + }) === context.metadataSnapshot + ? context + : undefined; } function findProviderById( @@ -412,13 +442,18 @@ function loadCapabilityProviderEntries( (registry?.[params.key] ?? []).filter((entry) => allowedPluginIds.has(entry.pluginId), ) as PluginRegistry[K]; - const registry = - getLoadedRuntimePluginRegistry({ - env: params.loadOptions.env, - loadOptions: params.loadOptions, - workspaceDir: params.loadOptions.workspaceDir, - requiredPluginIds: params.loadOptions.onlyPluginIds, - }) ?? resolveRuntimePluginRegistry(params.loadOptions); + const scopedRegistry = getPluginRuntimeGatewayRequestScope()?.pluginRegistry; + const loadedRegistry = scopedRegistry + ? registryContainsRuntimePluginIds(scopedRegistry, params.loadOptions.onlyPluginIds) + ? scopedRegistry + : undefined + : getLoadedRuntimePluginRegistry({ + env: params.loadOptions.env, + loadOptions: params.loadOptions, + workspaceDir: params.loadOptions.workspaceDir, + requiredPluginIds: params.loadOptions.onlyPluginIds, + }); + const registry = loadedRegistry ?? resolveRuntimePluginRegistry(params.loadOptions); const entries = filterAllowedEntries(registry); const missingRequested = params.requested && params.requested.size > 0 ? new Set(params.requested) : undefined; @@ -433,10 +468,8 @@ function loadCapabilityProviderEntries( } const captured = filterAllowedEntries( loadBundledCapabilityRuntimeRegistry({ + ...params.loadOptions, pluginIds: params.bundledCompatPluginIds, - env: process.env, - ...(params.loadOptions.config ? { config: params.loadOptions.config } : {}), - pluginSdkResolution: params.loadOptions.pluginSdkResolution, }), ); return entries.length > 0 ? mergeCapabilityProviderEntries(entries, captured) : captured; @@ -451,7 +484,8 @@ export function resolvePluginCapabilityProvider + // SAFETY: Only the setter above writes this optional registry-owned symbol slot. + (registry as RuntimeContextRegistry | undefined)?.[pluginRuntimeLoadContext]; + /** Runtime load option values that can be passed directly to plugin loading. */ type PluginRuntimeResolvedLoadValues = Pick< PluginLoadOptions, diff --git a/ui/src/components/app-sidebar-render.ts b/ui/src/components/app-sidebar-render.ts index 4177f4dfb27b..1d0d1b0d78ed 100644 --- a/ui/src/components/app-sidebar-render.ts +++ b/ui/src/components/app-sidebar-render.ts @@ -1,11 +1,12 @@ import { html, nothing } from "lit"; +import { repeat } from "lit/directives/repeat.js"; import type { GatewayControlUiPluginTab } from "../api/gateway.ts"; import { serializeSidebarEntry, type NavigationRouteId, type SidebarZoneEntry, } from "../app-navigation.ts"; -import { activityPersonLocation, isRouteId, isSessionRouteId } from "../app-route-paths.ts"; +import { isRouteId, isSessionRouteId } from "../app-route-paths.ts"; import { resolveControlUiAuthToken } from "../app/control-ui-auth.ts"; import { isNativeWebChromeHost } from "../app/native-web-chrome.ts"; import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts"; @@ -38,6 +39,7 @@ import type { SidebarRecentSession } from "./app-sidebar-session-types.ts"; import type { SidebarWorkboardBoard } from "./app-sidebar-workboard.ts"; import { icons } from "./icons.ts"; import { redactLoginFailureError } from "./login-gate.ts"; +import { personActivityLink, personActivityRouting } from "./person-activity-link.ts"; import { renderSessionAttentionIcon, renderSessionRunSpinner, @@ -315,29 +317,47 @@ export function renderAppSidebarOnline(host: AppSidebarRenderHost) { ${collapsed ? nothing : html``} `; diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index aa61f8ac8103..d29b2c102744 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -67,6 +67,7 @@ import { import { renderPanelRefreshStatus } from "./panel-refresh-status.ts"; import { SessionOrganizerController } from "./session-organizer-controller.ts"; import { SidebarMenusController } from "./sidebar-menus-controller.ts"; +import { SidebarPeopleController } from "./sidebar-people-controller.ts"; // The shared loader retries transient chunk failures online; a deploy-pruned // chunk still stays off until reload when that retry fails, by design. const lobsterPetImport = createIdleImport(() => import("./lobster-pet.runtime.ts")); @@ -77,6 +78,7 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi override readonly sessionOrganizer = new SessionOrganizerController(this); override readonly sidebarMenus = new SidebarMenusController(this); + private readonly people = new SidebarPeopleController(this); sessionGroupDefaults(name: string) { if (this.context?.sessions.groupsStatus() !== "ready") { @@ -204,6 +206,11 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi ); } + override dismissTransientMenus(): boolean { + const hadPersonCard = this.people.dismiss(); + return super.dismissTransientMenus() || hadPersonCard; + } + override disconnectedCallback() { window.removeEventListener("openclaw:native-gateways-changed", this.nativeGatewaysChanged); window.removeEventListener( diff --git a/ui/src/components/person-activity-card.ts b/ui/src/components/person-activity-card.ts new file mode 100644 index 000000000000..34d2328a498d --- /dev/null +++ b/ui/src/components/person-activity-card.ts @@ -0,0 +1,276 @@ +import { html, nothing } from "lit"; +import { repeat } from "lit/directives/repeat.js"; +import type { GatewaySessionRow } from "../api/types.ts"; +import { i18n, t } from "../i18n/index.ts"; +import { shouldHandleNavigationClick } from "../lib/navigation-click.ts"; +import type { PresenceViewer } from "../lib/presence-users.ts"; +import { resolveSessionDisplayName } from "../lib/session-display.ts"; +import { + resolveSessionPreferredFace, + sessionNavigationTarget, +} from "../lib/sessions/route-navigation.ts"; +import { + canonicalUiSessionKeyForPersistence, + normalizeAgentId, + parseAgentSessionKey, +} from "../lib/sessions/session-key.ts"; +import { icons } from "./icons.ts"; +import { personActivityLink, type PersonActivityRouting } from "./person-activity-link.ts"; +import type { SessionDataController } from "./session-data-controller.ts"; +import "./elapsed-time.ts"; +import "./viewer-facepile.ts"; + +type ScopedSession = { row: GatewaySessionRow; agentId: string }; +type PersonSessionData = Readonly< + Pick< + SessionDataController, + | "sessionsAgentId" + | "sessionsResult" + | "sessionResultsByAgent" + | "childSessionRowsByParent" + | "loadedChildSessionKeys" + > +>; +type PersonCardInput = { + user: PresenceViewer; + sessionData: PersonSessionData; + watchAgentId: string; + mainKey: string; + globalScope: boolean; + routing: PersonActivityRouting; + openSession: (row: GatewaySessionRow, agentId: string) => void; +}; + +/** Loaded, caller-visible roster facts, paired with their owning list scope. */ +function loadedPresenceSessions(data: PersonSessionData): ScopedSession[] { + const lists = [ + { agentId: data.sessionsAgentId, result: data.sessionsResult }, + ...Object.entries(data.sessionResultsByAgent).map(([agentId, result]) => ({ + agentId, + result, + })), + ]; + const roots = lists.flatMap(({ agentId, result }) => + agentId + ? (result?.sessions ?? []).map((row) => ({ + row, + agentId: parseAgentSessionKey(row.key)?.agentId ?? row.agentId ?? agentId, + })) + : [], + ); + const children = Object.entries(data.childSessionRowsByParent).flatMap(([parent, rows]) => { + const agentId = parseAgentSessionKey(parent)?.agentId ?? data.sessionsAgentId; + return agentId && data.loadedChildSessionKeys.has(parent) + ? rows.map((row) => ({ + row, + agentId: parseAgentSessionKey(row.key)?.agentId ?? row.agentId ?? agentId, + })) + : []; + }); + return [...roots, ...children]; +} + +function sessionIdentity(key: string, agentId: string, input: PersonCardInput): string { + const scope = parseAgentSessionKey(key)?.agentId ?? normalizeAgentId(agentId); + const canonical = canonicalUiSessionKeyForPersistence( + { + agentsList: { + defaultId: scope, + mainKey: input.mainKey, + scope: input.globalScope ? "global" : "agent", + }, + }, + parseAgentSessionKey(key) || key.toLowerCase() === "global" ? key : `agent:${scope}:${key}`, + ); + return `${scope}\u0000${canonical}`; +} + +function observedTimestamp(values: (number | undefined)[], order: "first" | "last") { + const known = values.filter((value): value is number => value !== undefined); + return known.length ? (order === "first" ? Math.min(...known) : Math.max(...known)) : undefined; +} + +function elapsed(timestamp: number) { + const date = new Date(timestamp); + return html``; +} + +function connections(user: PresenceViewer): string[] { + // Tabs with the same reported facts are one description, never a device count. + return [ + ...new Set( + (user.entries ?? []) + .map((entry) => { + const app = + entry.mode === "webchat" + ? t("presence.card.controlUi") + : entry.mode === "cli" + ? t("presence.card.cli") + : entry.mode === "ui" + ? t("presence.card.app") + : undefined; + return [ + ...new Set( + [entry.deviceFamily, entry.platform, app] + .map((value) => value?.trim()) + .filter(Boolean), + ), + ].join(" · "); + }) + .filter(Boolean), + ), + ].toSorted(); +} + +function renderSessions( + sessions: readonly ScopedSession[], + input: PersonCardInput, + recent: boolean, +) { + const title = t(recent ? "presence.card.recentSessions" : "presence.card.viewingNow"); + return html`
+

${title}

+ ${sessions.length + ? html`
+ ${repeat( + sessions.slice(0, 3), + ({ row, agentId }) => sessionIdentity(row.key, agentId, input), + ({ row, agentId }) => { + const target = sessionNavigationTarget({ + face: resolveSessionPreferredFace(row), + sessionKey: row.key, + fallbackAgentId: agentId, + basePath: input.routing.basePath, + row, + mainKey: input.mainKey, + }); + return html` { + if (!shouldHandleNavigationClick(event)) { + return; + } + event.preventDefault(); + input.openSession(row, agentId); + }} + > + ${resolveSessionDisplayName(row.key, row)} ${recent && + row.updatedAt != null + ? html`${t("presence.card.sessionUpdated")} ${elapsed(row.updatedAt)} + ${t("presence.card.ago")}` + : nothing} + `; + }, + )} +
` + : html`

+ ${t(recent ? "presence.card.noRecentSessions" : "presence.card.noVisibleSessions")} +

`} +
`; +} + +export function renderPersonActivityCard(input: PersonCardInput) { + const { user } = input; + const entries = user.entries ?? []; + const onlineSince = observedTimestamp( + entries.map((entry) => entry.onlineSince), + "first", + ); + const lastActivityAt = observedTimestamp( + entries.map((entry) => entry.lastActivityAt), + "last", + ); + const where = connections(user); + const zones = [ + ...new Set(entries.flatMap((entry) => (entry.timeZone?.trim() ? [entry.timeZone.trim()] : []))), + ].toSorted(); + const watched = new Set( + user.watchedSessions.map((key) => sessionIdentity(key, input.watchAgentId, input)), + ); + const unique = new Map(); + // Presence keys are only hints. Intersect the authorized roster before producing any text or href. + for (const session of loadedPresenceSessions(input.sessionData)) { + const key = sessionIdentity(session.row.key, session.agentId, input); + if (!unique.has(key)) { + unique.set(key, session); + } + } + const sessions = [...unique.values()].toSorted( + (a, b) => + (b.row.updatedAt ?? 0) - (a.row.updatedAt ?? 0) || + sessionIdentity(a.row.key, a.agentId, input).localeCompare( + sessionIdentity(b.row.key, b.agentId, input), + ), + ); + const viewing = sessions.filter(({ row, agentId }) => + watched.has(sessionIdentity(row.key, agentId, input)), + ); + const recent = sessions.filter( + ({ row, agentId }) => + !watched.has(sessionIdentity(row.key, agentId, input)) && + [row.owner?.actor, row.createdActor].some( + (actor) => actor?.type === "human" && actor.id === user.id, + ), + ); + const activity = personActivityLink(user.id, input.routing)!; + return html`
+
+ +
+

${user.name ?? user.email ?? t("presence.card.person")}

+ ${t("presence.rosterTitle")} +
+
+
+
+
${t("presence.card.onlineFor")}
+
+ ${onlineSince === undefined ? t("presence.card.notObserved") : elapsed(onlineSince)} +
+
+ ${where.length || zones.length + ? html`
+
${t("presence.card.where")}
+
+ ${where.map((description) => html`${description}`)}${zones.map( + (zone) => html`${t("presence.card.reportedTimeZone", { zone })}`, + )} +
+
` + : nothing} +
+
${t("presence.card.lastActivity")}
+
+ ${lastActivityAt === undefined + ? t("presence.card.notObserved") + : html`${elapsed(lastActivityAt)} ${t("presence.card.ago")}`} +
+
+
+ ${renderSessions(viewing, input, false)}${renderSessions(recent, input, true)} + +
`; +} diff --git a/ui/src/components/portaled-hovercard.ts b/ui/src/components/portaled-hovercard.ts index a708d70a8bd8..95ff790ed2f0 100644 --- a/ui/src/components/portaled-hovercard.ts +++ b/ui/src/components/portaled-hovercard.ts @@ -1,3 +1,5 @@ +import { promoteToPopoverTopLayer } from "./menu-surface.ts"; + const CARD_GAP = 10; const VIEWPORT_PADDING = 12; @@ -9,6 +11,7 @@ export class PortaledHovercardController { pointerOverCard = false; focusInside = false; cardFocusInside = false; + explicitHold = false; private closeTimer: number | null = null; private exitCleanup: (() => void) | null = null; @@ -25,7 +28,31 @@ export class PortaledHovercardController { ) {} get held(): boolean { - return this.pointerInside || this.pointerOverCard || this.focusInside || this.cardFocusInside; + return ( + this.explicitHold || + this.pointerInside || + this.pointerOverCard || + this.focusInside || + this.cardFocusInside + ); + } + + schedulePointerExit(event: PointerEvent, target: HTMLElement, bridgeMs = 220): void { + this.pointerInside = false; + const side = this.card?.dataset.side; + const rect = target.getBoundingClientRect(); + const towardCard = + (event.relatedTarget instanceof Node && this.card?.contains(event.relatedTarget)) || + (side === "right" && event.clientX >= rect.right) || + (side === "left" && event.clientX <= rect.left) || + (side === "bottom" && event.clientY >= rect.bottom) || + (side === "top" && event.clientY <= rect.top); + this.scheduleClose(towardCard ? bridgeMs : this.closeDelayMs); + } + + focusables(): HTMLElement[] { + // Decorative avatar twins opt out; cards share the same keyboard traversal contract. + return [...(this.card?.querySelectorAll('a[href]:not([tabindex="-1"])') ?? [])]; } scheduleOpen(delay: number, open: () => void): void { @@ -144,6 +171,7 @@ export class PortaledHovercardController { this.pointerOverCard = false; this.focusInside = false; this.cardFocusInside = false; + this.explicitHold = false; clearPortaledHovercardTrigger(this.trigger); this.clearCard(exitDurationMs); this.anchor = null; @@ -178,7 +206,11 @@ function mountPortaledHovercard(params: { placement: PortaledHovercardPlacement; observeVisualViewport?: boolean; }): () => void { - document.body.append(params.card); + // A modal drawer makes body siblings inert. Keep its card inside the same + // dialog, then use the existing menu top layer to escape clipping and stacking. + const owner = params.anchor.closest("openclaw-modal-dialog") ?? document.body; + owner.append(params.card); + promoteToPopoverTopLayer(params.card); params.trigger.setAttribute("aria-controls", params.card.id); params.trigger.setAttribute("aria-expanded", "true"); const position = () => positionPortaledHovercard(params.anchor, params.card, params.placement); diff --git a/ui/src/components/session-progress-hovercard.runtime.ts b/ui/src/components/session-progress-hovercard.runtime.ts index eff6ca7cc77e..9cda6fd734f4 100644 --- a/ui/src/components/session-progress-hovercard.runtime.ts +++ b/ui/src/components/session-progress-hovercard.runtime.ts @@ -29,7 +29,6 @@ import { const OPEN_DELAY_MS = 450; const SWEEP_OPEN_DELAY_MS = 80; const SKIP_DELAY_MS = 300; -const ROW_CARD_BRIDGE_MS = 220; const CLOSE_DELAY_MS = 100; const EXIT_DURATION_MS = 100; let nextHovercardId = 0; @@ -221,17 +220,7 @@ export class SessionProgressHovercardProvider extends ReactiveElement { if (event.relatedTarget instanceof Node && target.contains(event.relatedTarget)) { return; } - this.hovercard.pointerInside = false; - const card = this.hovercard.card; - const side = card?.dataset.side; - const rect = target.getBoundingClientRect(); - const movingTowardCard = - (event.relatedTarget instanceof Node && card?.contains(event.relatedTarget)) || - (side === "right" && event.clientX >= rect.right) || - (side === "left" && event.clientX <= rect.left) || - (side === "bottom" && event.clientY >= rect.bottom) || - (side === "top" && event.clientY <= rect.top); - this.hovercard.scheduleClose(movingTowardCard ? ROW_CARD_BRIDGE_MS : CLOSE_DELAY_MS); + this.hovercard.schedulePointerExit(event, target); }; private readonly handleFocusIn = (event: FocusEvent) => { @@ -567,10 +556,7 @@ export class SessionProgressHovercardProvider extends ReactiveElement { }; private cardFocusables(): HTMLElement[] { - // Decorative link twins (avatars beside their labelled link) opt out with tabindex="-1". - return [ - ...(this.hovercard.card?.querySelectorAll('a[href]:not([tabindex="-1"])') ?? []), - ]; + return this.hovercard.focusables(); } private personActivity(): PersonActivityRouting | undefined { diff --git a/ui/src/components/sidebar-people-controller.ts b/ui/src/components/sidebar-people-controller.ts new file mode 100644 index 000000000000..47d32e51236e --- /dev/null +++ b/ui/src/components/sidebar-people-controller.ts @@ -0,0 +1,122 @@ +import type { ReactiveController } from "lit"; +import { t } from "../i18n/index.ts"; +import { showToast } from "../lib/toast.ts"; +import type { AppSidebarSessionNavigationElement } from "./app-sidebar-session-navigation.ts"; +import type { SidebarPeopleRuntime } from "./sidebar-people.runtime.ts"; + +const EVENTS = ["pointerover", "pointerout", "focusin", "focusout", "click", "keydown"] as const; + +/** One lazy interaction owner per sidebar; the data stays in SessionDataController. */ +export class SidebarPeopleController implements ReactiveController { + private runtime: SidebarPeopleRuntime | null = null; + private loading: Promise | null = null; + private generation = 0; + private pendingTarget: HTMLElement | null = null; + + constructor(private readonly host: AppSidebarSessionNavigationElement) { + host.addController(this); + } + + hostConnected(): void { + for (const event of EVENTS) { + this.host.addEventListener(event, this.handleEvent); + } + } + + hostUpdated(): void { + this.runtime?.sync(); + } + + dismiss(): boolean { + this.generation += 1; + this.clearPending(); + return this.runtime?.dismiss() ?? false; + } + + hostDisconnected(): void { + for (const event of EVENTS) { + this.host.removeEventListener(event, this.handleEvent); + } + this.generation += 1; + this.clearPending(); + this.runtime?.dispose(); + this.runtime = null; + } + + private clearPending(): void { + this.pendingTarget = null; + document.removeEventListener("pointerdown", this.cancelPending, true); + document.removeEventListener("keydown", this.cancelPending, true); + } + + private readonly cancelPending = (event: Event) => { + if ( + event instanceof KeyboardEvent + ? event.key === "Escape" + : event.target instanceof Node && !this.pendingTarget?.contains(event.target) + ) { + this.generation += 1; + this.clearPending(); + } + }; + + private readonly handleEvent = (event: Event): void => { + if (this.runtime) { + this.runtime.handleEvent(event); + return; + } + const target = + event.target instanceof Element + ? event.target.closest(".sidebar-online__row") + : null; + if (!target || !["pointerover", "focusin", "click"].includes(event.type)) { + return; + } + // Input modality belongs to the runtime; row intent only warms its lazy code. + if ( + event.type === "click" && + !(event.target instanceof Element && event.target.closest(".sidebar-online__details")) + ) { + return; + } + const generation = ++this.generation; + this.pendingTarget = target; + document.addEventListener("pointerdown", this.cancelPending, true); + document.addEventListener("keydown", this.cancelPending, true); + const client = this.host.sessionDataContext?.gateway.snapshot.client; + const gateway = this.host.sessionDataContext?.gateway; + const hello = gateway?.snapshot.hello; + const route = this.host.activeRouteId; + const sessionKey = this.host.sessionKey; + const startedAt = performance.now(); + this.loading ??= import("./sidebar-people.runtime.ts"); + void this.loading.then( + (module) => { + if (generation === this.generation) { + this.clearPending(); + } + if ( + generation !== this.generation || + !this.host.isConnected || + !target.isConnected || + gateway !== this.host.sessionDataContext?.gateway || + client !== gateway?.snapshot.client || + hello !== gateway?.snapshot.hello || + route !== this.host.activeRouteId || + sessionKey !== this.host.sessionKey + ) { + return; + } + this.runtime ??= new module.SidebarPeopleRuntime(this.host); + this.runtime.handleEvent(event, startedAt); + }, + () => { + this.loading = null; + if (generation === this.generation && this.host.isConnected) { + this.clearPending(); + showToast({ message: t("presence.card.loadFailed") }); + } + }, + ); + }; +} diff --git a/ui/src/components/sidebar-people.runtime.ts b/ui/src/components/sidebar-people.runtime.ts new file mode 100644 index 000000000000..f52d3a7b669f --- /dev/null +++ b/ui/src/components/sidebar-people.runtime.ts @@ -0,0 +1,399 @@ +import { nothing, render } from "lit"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import { selectApplicationSession } from "../app/agent-selection.ts"; +import type { ApplicationGateway } from "../app/gateway.ts"; +import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts"; +import { i18n, t } from "../i18n/index.ts"; +import { projectOnlinePresenceViewers } from "../lib/presence-users.ts"; +import { runSessionNavigationIntent } from "../lib/sessions/navigation-handoff.ts"; +import { + resolveSessionPreferredFace, + sessionNavigationTarget, +} from "../lib/sessions/route-navigation.ts"; +import { + isUiGlobalScopeConfigured, + resolveUiConfiguredMainKey, + resolveUiDefaultAgentId, +} from "../lib/sessions/session-key.ts"; +import type { AppSidebarSessionNavigationElement } from "./app-sidebar-session-navigation.ts"; +import { + hovercardBootstrapIntentActive, + remainingHovercardOpenDelay, +} from "./lazy-hovercard-registration.ts"; +import { renderPersonActivityCard } from "./person-activity-card.ts"; +import { personActivityRouting } from "./person-activity-link.ts"; +import { createPortaledHovercard, PortaledHovercardController } from "./portaled-hovercard.ts"; + +let nextCardId = 0; + +export class SidebarPeopleRuntime { + private active: { + id: string; + row: HTMLElement; + trigger: HTMLElement; + scope: string; + gateway: ApplicationGateway; + client: GatewayBrowserClient | null; + } | null = null; + private readonly portal = new PortaledHovercardController(() => this.close(), 100); + private readonly observer = new MutationObserver(() => this.sync()); + private suppressFocus = false; + private lastOpenAt = -Infinity; + private readonly stopLocale: () => void; + + constructor(private readonly host: AppSidebarSessionNavigationElement) { + this.stopLocale = i18n.subscribe(() => this.sync()); + } + + private scope(): string { + return JSON.stringify([ + this.host.activeRouteId, + this.host.sessionKey, + this.host.sessionDataContext?.gateway.connectionRevision, + ]); + } + + handleEvent(event: Event, bootstrapStartedAt?: number): void { + const row = + event.target instanceof Element + ? event.target.closest(".sidebar-online__row") + : null; + const details = + event.target instanceof Element && event.target.closest(".sidebar-online__details"); + if (event.type === "keydown" && event instanceof KeyboardEvent) { + if (event.key === "Tab" && !event.shiftKey && event.target === this.active?.trigger) { + const first = this.portal.focusables()[0]; + if (first) { + event.preventDefault(); + first.focus(); + } + } + return; + } + if (!row) { + return; + } + // Import completion must not replay a hover or focus that already moved away. + if ( + bootstrapStartedAt !== undefined && + event.type !== "click" && + !hovercardBootstrapIntentActive(row, event.type === "focusin" ? "focus" : "pointer", true) + ) { + return; + } + if (event.type === "click") { + if (!details) { + this.close(); + return; + } + if (this.active?.row === row && this.portal.explicitHold) { + this.close(); + return; + } + this.activate(row, 0); + this.portal.explicitHold = true; + this.show(); + } else if (event.type === "pointerover" && event instanceof PointerEvent) { + if (event.pointerType === "touch" || !globalThis.matchMedia?.("(hover: hover)").matches) { + return; + } + if (this.portal.explicitHold && this.active?.row !== row) { + return; + } + const delay = this.portal.card || performance.now() - this.lastOpenAt < 300 ? 80 : 450; + this.activate( + row, + remainingHovercardOpenDelay(bootstrapStartedAt ?? performance.now(), delay), + ); + this.portal.pointerInside = true; + this.portal.clearClose(); + } else if ( + event.type === "pointerout" && + event instanceof PointerEvent && + this.active?.row === row + ) { + if (event.relatedTarget instanceof Node && row.contains(event.relatedTarget)) { + return; + } + this.portal.schedulePointerExit(event, row); + } else if (event.type === "focusin" && !this.suppressFocus) { + this.activate(row, 0); + this.portal.focusInside = true; + this.portal.clearClose(); + this.show(); + } else if ( + event.type === "focusout" && + event instanceof FocusEvent && + this.active?.row === row + ) { + if (event.relatedTarget instanceof Node && row.contains(event.relatedTarget)) { + return; + } + this.portal.focusInside = false; + this.portal.scheduleClose(); + } + } + + private activate(row: HTMLElement, delay: number): void { + const id = row.querySelector("[data-online-user-id]")?.dataset.onlineUserId; + const trigger = row.querySelector(".sidebar-online__details"); + if (!id || !trigger || !this.host.connected) { + return; + } + if (this.active?.id === id && this.active.row === row) { + return; + } + this.close(); + const gateway = this.host.sessionDataContext?.gateway; + if (!gateway || gateway.snapshot.phase !== "connected") { + return; + } + this.active = { + id, + row, + trigger, + scope: this.scope(), + gateway, + client: gateway.snapshot.client, + }; + this.portal.markTrigger(trigger); + this.observer.observe(this.host, { childList: true, subtree: true }); + document.addEventListener("pointerdown", this.outsidePointer, true); + document.addEventListener("focusin", this.outsideFocus, true); + document.addEventListener("keydown", this.outsideKey, true); + this.portal.scheduleOpen(delay, () => { + if (this.portal.held) { + this.show(); + } + }); + } + + private isCurrent(): boolean { + const active = this.active; + const gateway = this.host.sessionDataContext?.gateway; + return Boolean( + active && + this.host.isConnected && + this.host.connected && + gateway?.snapshot.phase === "connected" && + active.gateway === gateway && + active.client === gateway.snapshot.client && + active.scope === this.scope() && + this.host.contains(active.row) && + !this.host.collapsedSessionSections.has("online"), + ); + } + + sync(): void { + if (!this.isCurrent()) { + this.close(); + } else if (this.portal.card) { + this.show(); + } + } + + private show(): void { + const active = this.active; + const context = this.host.sessionDataContext; + if (!active || !context || !this.isCurrent()) { + this.close(); + return; + } + const data = this.host.sessionData; + const self = resolveCurrentSelfUser({ + snapshotUser: context.gateway.snapshot.selfUser, + presenceEntries: readPresenceEntries(data.presencePayload), + presenceInstanceId: data.presenceInstanceId, + }); + const user = projectOnlinePresenceViewers( + data.presencePayload, + self?.id, + data.presenceInstanceId, + ).find((person) => person.id === active.id); + if (!user) { + this.close(); + return; + } + const defaults = { + agentsList: context.agents.state.agentsList, + hello: context.gateway.snapshot.hello, + }; + const existing = this.portal.card; + const card = + existing ?? + createPortaledHovercard( + `openclaw-person-activity-${++nextCardId}`, + "session-progress-hovercard person-activity-hovercard", + ); + const focused = card.contains(document.activeElement) ? document.activeElement : null; + card.setAttribute( + "aria-label", + t("presence.card.ariaLabel", { name: user.name ?? user.email ?? t("presence.card.person") }), + ); + render( + renderPersonActivityCard({ + user, + sessionData: data, + watchAgentId: resolveUiDefaultAgentId(defaults), + mainKey: resolveUiConfiguredMainKey(defaults), + globalScope: isUiGlobalScopeConfigured(defaults), + routing: personActivityRouting( + { + basePath: this.host.basePath, + navigate: (route, options) => this.host.onNavigate?.(route, options), + }, + () => this.close(), + ), + openSession: (row, agentId) => { + const face = resolveSessionPreferredFace(row); + const target = sessionNavigationTarget({ + face, + sessionKey: row.key, + row, + fallbackAgentId: agentId, + basePath: this.host.basePath, + mainKey: resolveUiConfiguredMainKey(defaults), + }); + this.close(); + runSessionNavigationIntent(this.host, { + face, + sessionKey: row.key, + commit: () => { + if ( + this.host.sessionDataContext?.gateway !== active.gateway || + context.gateway.snapshot.client !== active.client || + context.gateway.snapshot.phase !== "connected" || + active.scope !== this.scope() + ) { + return false; + } + this.host.prepareSessionNavigation(row.key, target.options.pathname); + this.host.onNavigate?.(face, target.options); + selectApplicationSession({ + selection: context.agentSelection, + gateway: context.gateway, + sessionKey: row.key, + agentId, + }); + return true; + }, + }); + }, + }), + card, + ); + if (existing) { + if (focused && !card.contains(document.activeElement)) { + const replacement = + focused instanceof HTMLAnchorElement + ? this.portal + .focusables() + .find((link) => link instanceof HTMLAnchorElement && link.href === focused.href) + : undefined; + if (replacement) { + replacement.focus({ preventScroll: true }); + } else { + this.returnFocus(); + } + } + this.portal.position(); + return; + } + this.lastOpenAt = performance.now(); + card.addEventListener("pointerenter", () => { + this.portal.pointerOverCard = true; + this.portal.clearClose(); + }); + card.addEventListener("pointerleave", () => { + this.portal.pointerOverCard = false; + this.portal.scheduleClose(); + }); + card.addEventListener("focusin", () => { + this.portal.cardFocusInside = true; + this.portal.clearClose(); + }); + card.addEventListener("focusout", (event) => { + if (event.relatedTarget instanceof Node && card.contains(event.relatedTarget)) { + return; + } + this.portal.cardFocusInside = false; + this.portal.scheduleClose(); + }); + card.addEventListener("keydown", (event) => { + const links = this.portal.focusables(); + if ( + event.key === "Tab" && + document.activeElement === (event.shiftKey ? links[0] : links.at(-1)) + ) { + event.preventDefault(); + this.returnFocus(); + this.close(); + } + }); + this.portal.mount(active.row, card, "horizontal", true, () => render(nothing, card)); + } + + private returnFocus(): void { + this.suppressFocus = true; + this.active?.trigger.focus({ preventScroll: true }); + this.suppressFocus = false; + this.portal.focusInside = document.activeElement === this.active?.trigger; + } + + private readonly outsidePointer = (event: Event) => { + if ( + event.target instanceof Node && + !this.active?.row.contains(event.target) && + !this.portal.card?.contains(event.target) + ) { + this.close(); + } + }; + + private readonly outsideFocus = (event: Event) => { + if ( + event.target instanceof Node && + !this.active?.row.contains(event.target) && + !this.portal.card?.contains(event.target) + ) { + this.close(); + } + }; + + private readonly outsideKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + // The card owns this Escape, not the modal navigation drawer beneath it. + event.preventDefault(); + event.stopPropagation(); + if (this.portal.card?.contains(document.activeElement)) { + this.returnFocus(); + } + this.close(); + } + }; + + private close(): void { + if (this.portal.card) { + this.lastOpenAt = performance.now(); + } + this.observer.disconnect(); + document.removeEventListener("pointerdown", this.outsidePointer, true); + document.removeEventListener("focusin", this.outsideFocus, true); + document.removeEventListener("keydown", this.outsideKey, true); + this.portal.reset(); + this.active?.trigger.setAttribute("aria-haspopup", "dialog"); + this.active?.trigger.setAttribute("aria-expanded", "false"); + this.active = null; + } + + dismiss(): boolean { + const hadCard = this.active !== null; + this.close(); + return hadCard; + } + + dispose(): void { + this.close(); + this.stopLocale(); + } +} diff --git a/ui/src/e2e/chat-transcript-disclosure-anchor.e2e.test.ts b/ui/src/e2e/chat-transcript-disclosure-anchor.e2e.test.ts index d65cb928a213..33355e039ff8 100644 --- a/ui/src/e2e/chat-transcript-disclosure-anchor.e2e.test.ts +++ b/ui/src/e2e/chat-transcript-disclosure-anchor.e2e.test.ts @@ -187,6 +187,9 @@ suite.define(() => { // a wheel event or a changed offset. await page.mouse.click(track!.x + track!.width - 3, track!.y + 20); await page.locator(".chat-scroll-to-bottom").waitFor({ state: "visible" }); + // Chromium can commit its last canceled animation offset after the + // pointer action returns. Capture the reader before releasing text. + await waitForChatScrollIdle(page); } const interruptedOffset = await thread.evaluate((element) => element.scrollTop); if (interruption === "wheel") { diff --git a/ui/src/e2e/people-activity-card.e2e.test.ts b/ui/src/e2e/people-activity-card.e2e.test.ts new file mode 100644 index 000000000000..c8bdcc41da5e --- /dev/null +++ b/ui/src/e2e/people-activity-card.e2e.test.ts @@ -0,0 +1,228 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import type { Locator, Page } from "playwright"; +import { expect, it } from "vitest"; +import { + captureUiProofEnabled, + chatSessionListResponse, + controlUiSessionUrl, + createChatFlowE2eSuite, + installMockGateway, +} from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); +const selected = "agent:main:card-selected"; +const watched = "agent:main:card-viewing"; + +function scenario() { + const now = Date.now(); + return { + sessionKey: selected, + presenceUsers: [ + { + id: "alice", + name: "Alice", + onlineSince: now - 2_700_000, + lastActivityAt: now - 60_000, + deviceFamily: "Mac", + platform: "macOS", + timeZone: "Europe/Paris", + watchedSessions: [watched, "agent:main:main", "agent:private:hidden"], + }, + ], + methodResponses: { + "sessions.list": chatSessionListResponse([ + { key: "agent:main:main", kind: "direct", label: "", updatedAt: now - 90_000 }, + { key: selected, kind: "direct", label: "Selected session", updatedAt: now }, + { + key: watched, + kind: "direct", + label: "Release checklist", + updatedAt: now - 60_000, + boardFace: "dashboard", + }, + { + key: "agent:main:card-recent", + kind: "direct", + label: "Design notes", + updatedAt: now - 120_000, + createdActor: { type: "human", id: "alice" }, + }, + ]), + }, + }; +} + +async function expectInlineLastActivity(card: Locator) { + const positions = await card + .locator(".person-activity-card__facts dd") + .last() + .evaluate((value) => { + const time = value.querySelector("time"); + const text = document.createTreeWalker(value, NodeFilter.SHOW_TEXT); + let suffix = text.nextNode(); + while (suffix && suffix.textContent?.trim() !== "ago") { + suffix = text.nextNode(); + } + if (!time || !suffix) { + throw new Error("Expected last activity duration and suffix"); + } + const range = document.createRange(); + range.selectNodeContents(suffix); + return { time: time.getBoundingClientRect().top, suffix: range.getBoundingClientRect().top }; + }); + expect(Math.abs(positions.time - positions.suffix)).toBeLessThan(2); +} + +async function capturePeopleCard(page: Page, filename: string) { + if (!captureUiProofEnabled) { + return; + } + const directory = path.resolve(".artifacts/control-ui-e2e/people-activity-cards"); + await mkdir(directory, { recursive: true }); + await page.screenshot({ + path: path.join(directory, filename), + fullPage: true, + animations: "disabled", + }); +} + +suite.define(() => { + it("bridges hover, preserves focus on updates, and keeps navigation separate from details", async () => { + await suite.withPage( + { + hasTouch: false, + colorScheme: "light", + locale: "en-US", + serviceWorkers: "block", + viewport: { width: 1280, height: 900 }, + }, + async ({ page }) => { + const gateway = await installMockGateway(page, scenario()); + await page.goto(controlUiSessionUrl(suite.server.baseUrl, selected)); + const row = page + .locator(".sidebar-online__row") + .filter({ has: page.locator('[data-online-user-id="alice"]') }); + const name = row.locator("a.sidebar-online__person"); + const details = row.getByRole("button", { name: "Details for Alice" }); + const card = page.getByRole("dialog", { name: "Activity for Alice" }); + await name.waitFor({ state: "visible" }); + expect(await card.count()).toBe(0); + expect(await name.getAttribute("href")).toContain("/activity?person=alice"); + await name.hover(); + await card.waitFor({ state: "visible" }); + expect(await card.textContent()).toContain("Reported time zone: Europe/Paris"); + await expect + .poll(() => card.locator("a").allTextContents()) + .toEqual( + expect.arrayContaining([ + expect.stringContaining("Release checklist"), + expect.stringContaining("Main Session"), + expect.stringContaining("Design notes"), + expect.stringContaining("View activity"), + ]), + ); + expect(await card.innerHTML()).not.toContain("agent:private:hidden"); + await expectInlineLastActivity(card); + await capturePeopleCard(page, "desktop-light-open.png"); + const bounds = await row.boundingBox(); + const cardBounds = await card.boundingBox(); + if (!bounds || !cardBounds) { + throw new Error("Expected person row and card bounds"); + } + await page.mouse.move(bounds.x + bounds.width + 4, bounds.y + bounds.height / 2); + await page.mouse.move(cardBounds.x + 8, cardBounds.y + 20); + expect(await card.count()).toBe(1); + await details.focus(); + await page.keyboard.press("Tab"); + const session = card.getByRole("link", { name: "Release checklist" }); + await expect + .poll(() => session.evaluate((element) => document.activeElement === element)) + .toBe(true); + expect(await session.getAttribute("href")).toContain("/dashboard/"); + const current = scenario().presenceUsers[0]!; + await gateway.emitGatewayEvent("presence", { + presence: [ + { + ...current, + user: { id: "alice", name: "Alice" }, + lastInputSeconds: 600, + ts: Date.now(), + lastActivityAt: Date.now(), + }, + { user: { id: "bob", name: "Bob" }, ts: Date.now(), lastInputSeconds: 0 }, + ], + }); + await expect + .poll(() => + page.locator(".sidebar-online__person").first().getAttribute("data-online-user-id"), + ) + .toBe("bob"); + expect(await session.evaluate((element) => document.activeElement === element)).toBe(true); + await page.keyboard.press("Escape"); + await expect.poll(() => card.count()).toBe(0); + expect(await details.evaluate((element) => document.activeElement === element)).toBe(true); + await details.click(); + await card.waitFor({ state: "visible" }); + await page.mouse.move(1100, 850); + expect(await card.count()).toBe(1); + await page.mouse.click(1100, 850); + await expect.poll(() => card.count()).toBe(0); + await details.click(); + await card.waitFor({ state: "visible" }); + await card.getByRole("link", { name: "View activity", exact: true }).click(); + await expect.poll(() => page.url()).toContain("/activity?person=alice"); + await expect.poll(() => card.count()).toBe(0); + }, + ); + }); + + it("opens touch details inside a narrow viewport and follows the session's saved face", async () => { + await suite.withPage( + { + hasTouch: true, + isMobile: true, + colorScheme: "dark", + reducedMotion: "reduce", + locale: "en-US", + serviceWorkers: "block", + viewport: { width: 390, height: 650 }, + }, + async ({ page }) => { + await installMockGateway(page, scenario()); + await page.goto(controlUiSessionUrl(suite.server.baseUrl, selected)); + await page + .locator(".topbar-nav-toggle:visible, .chat-pane__nav-toggle:visible") + .first() + .click(); + const details = page.getByRole("button", { name: "Details for Alice" }); + await details.tap(); + const card = page.getByRole("dialog", { name: "Activity for Alice" }); + await card.waitFor({ state: "visible" }); + await page.keyboard.press("Tab"); + await page.keyboard.press("Escape"); + await expect.poll(() => card.count()).toBe(0); + expect(await details.isVisible()).toBe(true); + expect(await details.evaluate((element) => document.activeElement === element)).toBe(true); + await details.tap(); + await card.waitFor({ state: "visible" }); + expect(await card.evaluate((element) => getComputedStyle(element).pointerEvents)).toBe( + "auto", + ); + const bounds = await card.boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds!.x).toBeGreaterThanOrEqual(0); + expect(bounds!.y).toBeGreaterThanOrEqual(0); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390); + expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(650); + const session = card.getByRole("link", { name: "Release checklist" }); + await session.click({ trial: true }); + await expectInlineLastActivity(card); + await capturePeopleCard(page, "touch-dark-open.png"); + await session.tap(); + await expect.poll(() => page.url()).toContain("/dashboard/"); + await expect.poll(() => card.count()).toBe(0); + }, + ); + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 267f36303ba0..5e83e801e2ba 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -3589,6 +3589,27 @@ export const en: TranslationMap = { }, presence: { rosterTitle: "Online", + card: { + details: "Details for {name}", + loadFailed: "Could not open details. Try again, or open this person’s Activity page.", + ariaLabel: "Activity for {name}", + person: "Person", + onlineFor: "Online for", + where: "Where", + reportedTimeZone: "Reported time zone: {zone}", + lastActivity: "Last activity", + notObserved: "Not observed yet", + viewingNow: "Viewing now", + recentSessions: "Recent sessions", + noVisibleSessions: "No visible sessions being viewed.", + noRecentSessions: "No recent visible sessions.", + sessionUpdated: "Session updated", + ago: "ago", + viewActivity: "View activity", + controlUi: "Control UI", + cli: "Command line", + app: "App", + }, }, activityFeed: { sessionsMode: "Sessions", diff --git a/ui/src/pages/chat/components/chat-transcript-controller.test.ts b/ui/src/pages/chat/components/chat-transcript-controller.test.ts index 97daf642e553..14cac78e41ff 100644 --- a/ui/src/pages/chat/components/chat-transcript-controller.test.ts +++ b/ui/src/pages/chat/components/chat-transcript-controller.test.ts @@ -414,6 +414,56 @@ describe("chat transcript controller", () => { }, ); + it("keeps a smooth latest command through an idle observer delivery before reaching its target", async () => { + const rows: TestContentRow[] = Array.from({ length: 40 }, (_, index) => ({ + kind: "content", + key: `row:${index}`, + content: html`
row ${index}
`, + })); + const { container, transcript } = await mountTestTranscript("idle-latest", rows); + Object.defineProperties(container, { + clientHeight: { configurable: true, value: 600 }, + scrollHeight: { configurable: true, value: 4800 }, + }); + const scrollTo = vi.fn(); + container.scrollTo = scrollTo; + vi.useFakeTimers(); + try { + container.scrollTop = 1000; + container.dispatchEvent(new Event("scroll")); + transcript.scrollToEnd({ behavior: "smooth" }); + expect(scrollTo).toHaveBeenLastCalledWith({ top: 4200, behavior: "smooth" }); + container.scrollTop = 1500; + container.dispatchEvent(new Event("scroll")); + Object.defineProperty(container, "scrollHeight", { configurable: true, value: 4900 }); + vi.advanceTimersByTime(16); + expect(scrollTo).toHaveBeenLastCalledWith({ top: 4300, behavior: "smooth" }); + scrollTo.mockClear(); + + // A retargeted native animation can pause between offset events. Core's + // idle debounce still fires, but the requested end has not been reached. + vi.advanceTimersByTime(150); + expect(transcript.isProgrammaticScroll).toBe(true); + expect(scrollTo).not.toHaveBeenCalled(); + + // The 8px UI-follow boundary does not complete the native end command. + container.scrollTop = 4296; + container.dispatchEvent(new Event("scroll")); + vi.advanceTimersByTime(150); + expect(transcript.isProgrammaticScroll).toBe(false); + expect(scrollTo).not.toHaveBeenCalled(); + + container.scrollTop = 4300; + container.dispatchEvent(new Event("scroll")); + vi.advanceTimersByTime(150); + expect(scrollTo).toHaveBeenCalledExactlyOnceWith({ top: 4300, behavior: "instant" }); + expect(transcript.isProgrammaticScroll).toBe(false); + } finally { + transcript.hostDisconnected(); + vi.useRealTimers(); + } + }); + it("remeasures every visible pane transcript while preserving hidden transcript rows", async () => { const host = Object.assign(document.body.appendChild(document.createElement("div")), { addController: vi.fn(), diff --git a/ui/src/pages/chat/components/chat-transcript-controller.ts b/ui/src/pages/chat/components/chat-transcript-controller.ts index b3c2c04fc8fe..be5b475fb9af 100644 --- a/ui/src/pages/chat/components/chat-transcript-controller.ts +++ b/ui/src/pages/chat/components/chat-transcript-controller.ts @@ -293,17 +293,19 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatTranscri if (element !== this.scrollElement) { return; } - const previousOffset = instance.scrollOffset; callback(offset, scrolling); - // Core must observe the user's offset before replaying skipped sizes; - // otherwise its old destination can compensate the viewport back down. - if ( - this.endScrollBehavior === "smooth" && - (!scrolling || offset < (previousOffset ?? offset)) - ) { - this.cancelScroll(); - } else if (!scrolling) { - this.endScrollBehavior = null; + // Idle can arrive between smooth retargets. Completion needs the + // restore path's 1px precision, not the 8px UI-follow boundary. + // The input listeners above own reader takeover. + const settledAtEnd = + !scrolling && + Math.abs((maxTranscriptScrollOffset(element) ?? 0) - (element?.scrollTop ?? 0)) <= 1; + if (settledAtEnd && this.pendingScrollOffset === null) { + if (this.endScrollBehavior === "smooth") { + this.cancelScroll(); + } else { + this.endScrollBehavior = null; + } } }); return () => { diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 1f7806aef8b2..135915695f68 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -892,10 +892,12 @@ openclaw-session-progress-hovercard-provider { --session-hovercard-shift-y: 0; position: fixed; + inset: auto; z-index: 1990; width: min(296px, calc(100vw - 24px)); max-height: min(520px, calc(100vh - 24px)); padding: 0; + margin: 0; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-lg); @@ -958,8 +960,166 @@ openclaw-session-progress-hovercard-provider { } } +/* Explicit details remain interactive on touch devices as well as hover pointers. */ +.person-activity-hovercard[data-open="true"] { + pointer-events: auto; +} + +.person-activity-card { + font-size: calc(12px * var(--control-ui-text-scale)); + line-height: 1.5; +} + +.person-activity-card__header { + display: flex; + align-items: center; + gap: 10px; + padding: 16px 16px 12px; +} + +.person-activity-card__header > div { + min-width: 0; +} + +.person-activity-card h2, +.person-activity-card h3, +.person-activity-card p { + margin: 0; +} + +.person-activity-card h2 { + overflow-wrap: anywhere; + font-size: calc(14px * var(--control-ui-text-scale)); + font-weight: 600; +} + +.person-activity-card__status { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--muted); +} + +.person-activity-card__status > span { + width: 6px; + height: 6px; + border-radius: var(--radius-full); + background: var(--ok); +} + +.person-activity-card__facts { + display: grid; + gap: 12px; + padding: 0 16px 16px; + margin: 0; +} + +.person-activity-card__facts > div { + display: grid; + grid-template-columns: 80px minmax(0, 1fr); + gap: 8px; +} + +.person-activity-card__facts dt, +.person-activity-card__muted, +.person-activity-card small { + color: var(--muted); +} + +.person-activity-card__facts dd { + display: grid; + gap: 3px; + margin: 0; + overflow-wrap: anywhere; +} + +.person-activity-card small { + display: block; + font-size: calc(11px * var(--control-ui-text-scale)); +} + +.person-activity-card__section, +.person-activity-card footer { + padding: 12px 16px; + border-top: 1px solid var(--border); +} + +.person-activity-card h3 { + margin-bottom: 6px; + color: var(--muted); + font-size: inherit; + font-weight: 500; +} + +.person-activity-card__sessions { + display: grid; + gap: 2px; +} + +.person-activity-card__session { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px; + margin: 0 -6px; + border-radius: var(--radius-sm); + color: var(--text); + text-decoration: none; +} + +.person-activity-card__session-copy { + min-width: 0; + overflow-wrap: anywhere; +} + +.person-activity-card__session-copy > span { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.person-activity-card__session-icon { + flex: none; + margin-top: 2px; + color: var(--muted); +} + +.person-activity-card svg { + display: block; + width: 14px; + height: 14px; +} + +.person-activity-card footer a { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--text); + text-decoration: none; +} + +.person-activity-card a:hover { + background: var(--bg-hover); +} + +.person-activity-card a:focus-visible { + border-radius: var(--radius-sm); + outline: none; + box-shadow: var(--focus-ring); +} + +@media (pointer: coarse) { + .person-activity-card a { + min-height: 44px; + align-items: center; + } +} + .github-link-hovercard { position: fixed; + inset: auto; + margin: 0; z-index: 1990; width: min(440px, calc(100vw - 24px)); min-height: 112px; diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 1c0a2a7774a5..b86ec328d0dd 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -2181,7 +2181,15 @@ openclaw-settings-save-indicator:empty { overflow: hidden; } +.sidebar-online__row { + display: flex; + align-items: center; + min-width: 0; + border-radius: var(--radius-md); +} + .sidebar-online__person { + flex: 1; display: flex; align-items: center; gap: 8px; @@ -2222,25 +2230,61 @@ openclaw-settings-save-indicator:empty { white-space: nowrap; } -.sidebar-online__person-action { +.sidebar-online__details { display: inline-flex; - margin-left: auto; + align-items: center; + justify-content: center; + flex: none; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--radius-sm); + background: transparent; color: var(--muted); opacity: 0; transition: opacity var(--duration-fast) ease; } -.sidebar-online__person:hover .sidebar-online__person-action, -.sidebar-online__person:focus-visible .sidebar-online__person-action { +.sidebar-online__row:hover .sidebar-online__details, +.sidebar-online__row:focus-within .sidebar-online__details, +.sidebar-online__details[aria-expanded="true"] { opacity: 1; } -.sidebar-online__person-action svg { +.sidebar-online__details:hover { + background: var(--bg-hover); + color: var(--text); +} + +.sidebar-online__details:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.sidebar-online__details svg { + display: block; width: 13px; height: 13px; - fill: none; - stroke: currentColor; - stroke-width: 1.6px; +} + +@media (hover: none), (pointer: coarse) { + .sidebar-online__details { + width: 44px; + height: 44px; + opacity: 1; + } + + .sidebar-online__person { + min-height: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + .sidebar-online__person, + .sidebar-online__details { + transition: none; + } } .sidebar-zone-entry { diff --git a/ui/src/test-helpers/app-sidebar-cases/presence.ts b/ui/src/test-helpers/app-sidebar-cases/presence.ts index cc13cb2eece5..89b2a4ff7e04 100644 --- a/ui/src/test-helpers/app-sidebar-cases/presence.ts +++ b/ui/src/test-helpers/app-sidebar-cases/presence.ts @@ -1,6 +1,12 @@ +import type { LitElement } from "lit"; import { describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; -import { createGatewayHarness, createSessions, mountSidebar } from "../app-sidebar.ts"; +import { + createGatewayHarness, + createSessions, + createSessionsHarness, + mountSidebar, +} from "../app-sidebar.ts"; import "../../components/app-sidebar.ts"; await import("../../components/viewer-facepile.ts"); @@ -87,13 +93,255 @@ describe("AppSidebar viewer presence", () => { await sidebar.updateComplete; expect(sidebar.querySelectorAll(".sidebar-online__person")).toHaveLength(3); - sidebar.querySelector('[data-online-user-id="alice"]')?.click(); + const aliceLink = sidebar.querySelector('[data-online-user-id="alice"]')!; + for (const options of [ + { ctrlKey: true }, + { metaKey: true }, + { shiftKey: true }, + { button: 1 }, + ]) { + const click = new MouseEvent("click", { ...options, bubbles: true, cancelable: true }); + let intercepted = false; + aliceLink.addEventListener( + "click", + (event) => { + intercepted = event.defaultPrevented; + event.preventDefault(); // Keep jsdom from attempting a new browsing context. + }, + { once: true }, + ); + aliceLink.dispatchEvent(click); + expect(intercepted).toBe(false); + } + expect(onNavigate).not.toHaveBeenCalled(); + aliceLink.click(); expect(onNavigate).toHaveBeenCalledWith("activity", { + href: "/activity?person=alice", pathname: "/activity", search: "?person=alice", }); }); + it("projects only visible sessions and reported facts without guessing timing or devices", async () => { + const gateway = createGatewayHarness({ instanceId: "self" } as GatewayBrowserClient); + const sessions = createSessionsHarness("research", [ + "watched", + "global", + "agent:research:ambiguous", + "agent:research:robot", + ...[1, 2, 3, 4].map((n) => `agent:research:recent-${n}`), + ]); + const result = sessions.sessions.state.result!; + result.sessions.forEach((row, index) => { + row.label = row.key === "global" ? "Research global" : `Visible ${index}`; + row.updatedAt = Date.now() - index * 60_000; + if (index === 2) { + row.participants = [{ type: "human", id: "alice", label: "Alice" }]; + } + if (index === 3) { + row.createdActor = { type: "agent", id: "alice" }; + } + if (index >= 4) { + row.owner = { actor: { type: "human", id: "alice" } }; + } + }); + sessions.publishList({ result }); + const { sidebar } = await mountSidebar(gateway.gateway, sessions.sessions); + sidebar.connected = true; + gateway.publishEvent("presence", { + presence: [1, 2, 3].map((tab) => ({ + ts: Date.now() - 500_000, + lastInputSeconds: 3, + instanceId: `private-tab-${tab}`, + ip: "192.0.2.12", + host: "internal-host", + deviceFamily: tab === 3 ? "iPhone" : "Mac", + platform: tab === 3 ? "iOS" : "macOS", + mode: "webchat", + timeZone: "Europe/Paris", + user: { id: "alice", name: "Alice" }, + watchedSessions: [ + "AGENT:research:watched", + "agent:research:watched", + "agent:private:secret-title", + "global", + ], + })), + }); + await sidebar.updateComplete; + sidebar.querySelector(".sidebar-online__details")!.click(); + await vi.waitFor(() => + expect(document.querySelector(".person-activity-hovercard")).not.toBeNull(), + ); + const card = document.querySelector(".person-activity-hovercard")!; + expect(card.querySelectorAll("dt")).toHaveLength(3); + const facts = card.querySelectorAll("dd"); + expect(facts[0]?.textContent?.trim()).toBe("Not observed yet"); + expect([...facts[1]!.querySelectorAll("span")].map((node) => node.textContent)).toEqual([ + "Mac · macOS · Control UI", + "iPhone · iOS · Control UI", + ]); + expect(facts[1]?.querySelector("small")?.textContent).toBe("Reported time zone: Europe/Paris"); + expect(facts[2]?.textContent?.trim()).toBe("Not observed yet"); + const sections = card.querySelectorAll("section"); + expect(sections[0]?.querySelectorAll("a")).toHaveLength(1); + expect(sections[0]?.textContent).toContain("Visible 0"); + expect(sections[0]?.querySelector("a")?.getAttribute("href")).toBe("/chat/research/watched"); + expect(sections[1]?.querySelectorAll("a")).toHaveLength(3); + expect(sections[1]?.textContent).toContain("Session updated"); + for (const hidden of [ + "secret-title", + "private-tab", + "internal-host", + "192.0.2.12", + "Research global", + "Visible 2", + "Visible 3", + "Visible 7", + ]) { + expect(card.outerHTML).not.toContain(hidden); + } + expect(card.querySelectorAll("[data-viewer-id]")).toHaveLength(0); + }); + + it("keeps the active identity and focused session link across presence reordering", async () => { + const gateway = createGatewayHarness({ instanceId: "self" } as GatewayBrowserClient); + const sessions = createSessionsHarness("main", ["agent:main:work"]); + const result = sessions.sessions.state.result!; + sessions.publishList({ + result: { + ...result, + sessions: result.sessions.map((row) => ({ + ...row, + createdActor: { type: "human" as const, id: "alice" }, + })), + }, + }); + const { sidebar } = await mountSidebar(gateway.gateway, sessions.sessions); + sidebar.connected = true; + const now = Date.now(); + const alice = { + ts: now, + user: { id: "alice", name: "Alice" }, + watchedSessions: ["agent:main:work"], + onlineSince: now - 60_000, + lastActivityAt: now - 10_000, + lastInputSeconds: 0, + }; + const bob = { ts: now, user: { id: "bob", name: "Bob" }, lastInputSeconds: 0 }; + gateway.publishEvent("presence", { presence: [alice, bob] }); + await sidebar.updateComplete; + const aliceLink = sidebar.querySelector('[data-online-user-id="alice"]')!; + const button = aliceLink.parentElement!.querySelector("button")!; + button.click(); + await vi.waitFor(() => + expect(document.querySelector(".person-activity-hovercard")).not.toBeNull(), + ); + const card = document.querySelector(".person-activity-hovercard")!; + const sessionLink = card.querySelector(".person-activity-card__session")!; + sessionLink.focus(); + gateway.publishEvent("presence", { + presence: [{ ...alice, lastInputSeconds: 600, lastActivityAt: now }, bob], + }); + await sidebar.updateComplete; + expect(sidebar.querySelector('[data-online-user-id="alice"]')).toBe(aliceLink); + expect( + sidebar.querySelector(".sidebar-online__person")?.getAttribute("data-online-user-id"), + ).toBe("bob"); + expect(card.querySelector("h2")?.textContent).toBe("Alice"); + expect(document.activeElement).toBe(sessionLink); + expect(card.querySelector("time")?.getAttribute("datetime")).toBe( + new Date(alice.onlineSince).toISOString(), + ); + gateway.publishEvent("presence", { presence: [{ ...alice, watchedSessions: [] }, bob] }); + await sidebar.updateComplete; + expect(document.activeElement?.getAttribute("href")).toBe(sessionLink.getAttribute("href")); + expect(document.activeElement?.closest("section")?.querySelector("h3")?.textContent).toBe( + "Recent sessions", + ); + sessions.publishList({ result: { ...result, sessions: [], count: 0 } }); + await sidebar.updateComplete; + expect(document.activeElement).toBe(button); + button.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(document.querySelector(".person-activity-hovercard")).toBeNull(); + expect(document.activeElement).toBe(button); + expect(button.getAttribute("aria-expanded")).toBe("false"); + }); + + it.each(["disconnect", "switch", "route", "collapse", "shell", "remove", "teardown"] as const)( + "dismisses and releases card timers on %s", + async (reason) => { + const gateway = createGatewayHarness({ instanceId: "self" } as GatewayBrowserClient); + const { sidebar, provider } = await mountSidebar( + gateway.gateway, + createSessions("main", ["agent:main:work"]), + ); + sidebar.connected = true; + const person = { + ts: Date.now(), + user: { id: "alice", name: "Alice" }, + onlineSince: Date.now() - 90_000, + }; + gateway.publishEvent("presence", { + presence: [person], + }); + await sidebar.updateComplete; + vi.useFakeTimers(); + sidebar.querySelector(".sidebar-online__details")!.click(); + await vi.waitFor(() => + expect(document.querySelector("openclaw-elapsed-time")?.textContent).toBeTruthy(), + ); + const elapsed = document.querySelector("openclaw-elapsed-time")!; + await elapsed.updateComplete; + const elapsedBeforeDismissal = elapsed.textContent; + if (reason === "disconnect") { + gateway.publish({ phase: "reconnecting" }); + } + if (reason === "switch") { + gateway.publish({ client: { instanceId: "replacement" } as GatewayBrowserClient }); + } + if (reason === "route") { + sidebar.activeRouteId = "activity"; + } + if (reason === "shell") { + sidebar.dismissTransientMenus(); + } + if (reason === "collapse") { + sidebar + .querySelector('.sidebar-online button[aria-label="Online"]')! + .click(); + } + if (reason === "remove") { + gateway.publishEvent("presence", { presence: [{ ...person, reason: "disconnect" }] }); + } + if (reason === "teardown") { + provider.remove(); + } + await sidebar.updateComplete; + await vi.waitFor(() => + expect(document.querySelector(".person-activity-hovercard")).toBeNull(), + ); + await vi.advanceTimersByTimeAsync(1_000); + await elapsed.updateComplete; + expect(elapsed.textContent).toBe(elapsedBeforeDismissal); + if (reason === "remove") { + expect(sidebar.querySelector(".sidebar-online")).toBeNull(); + const returned = { ...person, ts: Date.now(), onlineSince: Date.now() }; + gateway.publishEvent("presence", { + presence: [{ ...person, reason: "disconnect" }, returned], + }); + await sidebar.updateComplete; + sidebar.querySelector(".sidebar-online__details")!.click(); + await vi.waitFor(() => + expect( + document.querySelector(".person-activity-hovercard time")?.getAttribute("datetime"), + ).toBe(new Date(returned.onlineSince).toISOString()), + ); + expect(document.querySelector(".person-activity-hovercard h2")?.textContent).toBe("Alice"); + } + }, + ); + it("restores the collapsed online section", async () => { localStorage.setItem( "openclaw:sidebar:sessions:collapsed-sections", diff --git a/ui/src/test-helpers/app-sidebar.ts b/ui/src/test-helpers/app-sidebar.ts index 8f1b52390c49..49b9acbe6041 100644 --- a/ui/src/test-helpers/app-sidebar.ts +++ b/ui/src/test-helpers/app-sidebar.ts @@ -79,6 +79,7 @@ export type SidebarLifecycleState = HTMLElement & { routeId: string, options?: { pathname?: string; search?: string; hash?: string }, ) => void; + dismissTransientMenus: () => boolean; readonly sessionData: SessionDataController; readonly sessionOrganizer: SessionOrganizerController; listSessionGroupFolders(path?: string): Promise<{ diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 37dfcbe0ab41..dea837a5bca3 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -334,6 +334,9 @@ export type ControlUiMockGatewayScenario = { host?: string; instanceId?: string; lastInputSeconds?: number; + onlineSince?: number; + lastActivityAt?: number; + timeZone?: string; mode?: string; platform?: string; ts?: number; @@ -1380,6 +1383,9 @@ function installControlUiMockGateway( ...(user.platform ? { platform: user.platform } : {}), ...(user.deviceFamily ? { deviceFamily: user.deviceFamily } : {}), ...(user.lastInputSeconds === undefined ? {} : { lastInputSeconds: user.lastInputSeconds }), + ...(user.onlineSince === undefined ? {} : { onlineSince: user.onlineSince }), + ...(user.lastActivityAt === undefined ? {} : { lastActivityAt: user.lastActivityAt }), + ...(user.timeZone ? { timeZone: user.timeZone } : {}), user: { id: user.id, name: user.name ?? null,