ocuclaw 0.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -8
- package/dist/config/runtime-config.js +81 -3
- package/dist/domain/activity-status-adapter.js +138 -605
- package/dist/domain/activity-status-arbiter.js +109 -0
- package/dist/domain/activity-status-labels.js +906 -0
- package/dist/domain/code-span-regions.js +103 -0
- package/dist/domain/conversation-state.js +14 -1
- package/dist/domain/debug-store.js +41 -184
- package/dist/domain/glasses-ui-content-summary.js +62 -0
- package/dist/domain/glasses-ui-system-prompt.js +28 -0
- package/dist/domain/message-emoji-allowlist.js +16 -0
- package/dist/domain/message-emoji-filter.js +33 -55
- package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
- package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
- package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
- package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
- package/dist/domain/tagged-span-parser.js +121 -0
- package/dist/domain/tagged-span-strip.js +38 -0
- package/dist/even-ai/even-ai-endpoint.js +91 -0
- package/dist/even-ai/even-ai-run-waiter.js +14 -0
- package/dist/even-ai/even-ai-settings-store.js +14 -0
- package/dist/gateway/gateway-bridge.js +14 -2
- package/dist/gateway/gateway-timing-ledger.js +457 -0
- package/dist/gateway/openclaw-client.js +462 -38
- package/dist/index.js +28 -1
- package/dist/runtime/downstream-handler.js +909 -68
- package/dist/runtime/downstream-server.js +1004 -512
- package/dist/runtime/ocuclaw-settings-store.js +74 -31
- package/dist/runtime/plugin-update-service.js +216 -0
- package/dist/runtime/protocol-adapter.js +9 -0
- package/dist/runtime/provider-usage-select.js +168 -0
- package/dist/runtime/relay-client-nudge-controller.js +553 -0
- package/dist/runtime/relay-core.js +1357 -210
- package/dist/runtime/relay-health-monitor.js +172 -0
- package/dist/runtime/relay-operation-registry.js +263 -0
- package/dist/runtime/relay-service.js +201 -1
- package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
- package/dist/runtime/relay-worker-entry.js +32 -0
- package/dist/runtime/relay-worker-health.js +272 -0
- package/dist/runtime/relay-worker-protocol.js +285 -0
- package/dist/runtime/relay-worker-queue.js +202 -0
- package/dist/runtime/relay-worker-supervisor.js +1081 -0
- package/dist/runtime/relay-worker-transport.js +1051 -0
- package/dist/runtime/session-context-service.js +189 -0
- package/dist/runtime/session-service.js +656 -38
- package/dist/runtime/upstream-runtime.js +1167 -60
- package/dist/tools/device-info-tool.js +242 -0
- package/dist/tools/glasses-ui-cron.js +427 -0
- package/dist/tools/glasses-ui-descriptors.js +261 -0
- package/dist/tools/glasses-ui-limits.js +21 -0
- package/dist/tools/glasses-ui-paint-floor.js +99 -0
- package/dist/tools/glasses-ui-recipes.js +746 -0
- package/dist/tools/glasses-ui-surfaces.js +278 -0
- package/dist/tools/glasses-ui-template.js +182 -0
- package/dist/tools/glasses-ui-tool.js +1147 -0
- package/dist/tools/session-title-tool.js +209 -0
- package/dist/version.js +2 -0
- package/openclaw.plugin.json +163 -15
- package/package.json +12 -4
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as WebSocketModule from "ws";
|
|
2
|
+
import { createRelayClientNudgeController } from "./relay-client-nudge-controller.js";
|
|
3
|
+
import * as RelayWorkerProtocol from "./relay-worker-protocol.js";
|
|
2
4
|
|
|
3
5
|
// `ws` exposes different server entrypoints across ESM/CJS consumers.
|
|
4
6
|
const WebSocket =
|
|
@@ -6,6 +8,12 @@ const WebSocket =
|
|
|
6
8
|
const WebSocketServer =
|
|
7
9
|
WebSocketModule.WebSocketServer || WebSocketModule.Server || WebSocket.Server;
|
|
8
10
|
|
|
11
|
+
// Bounded wait for the single app client to answer a forwarded automation
|
|
12
|
+
// state request. Pending entries are otherwise cleared only by a matching
|
|
13
|
+
// reply or a disconnect, so a connected-but-silent app would leave the
|
|
14
|
+
// requester waiting forever.
|
|
15
|
+
const AUTOMATION_STATE_REPLY_TIMEOUT_MS = 1500;
|
|
16
|
+
|
|
9
17
|
function normalizeLogger(logger) {
|
|
10
18
|
if (!logger || typeof logger !== "object") {
|
|
11
19
|
return console;
|
|
@@ -60,54 +68,93 @@ function createDownstreamServer(opts) {
|
|
|
60
68
|
const getCurrentStatus = opts.getCurrentStatus;
|
|
61
69
|
const getCurrentDebugConfig = opts.getCurrentDebugConfig || null;
|
|
62
70
|
const getCurrentResumeState = opts.getCurrentResumeState || null;
|
|
71
|
+
const getAgentAvatarDataUriByHash =
|
|
72
|
+
typeof opts.getAgentAvatarDataUriByHash === "function"
|
|
73
|
+
? opts.getAgentAvatarDataUriByHash
|
|
74
|
+
: () => null;
|
|
63
75
|
const token = opts.token;
|
|
64
76
|
const onClientConnected = opts.onClientConnected || null;
|
|
65
77
|
const onClientDisconnected = opts.onClientDisconnected || null;
|
|
66
78
|
const onTransportControl = opts.onTransportControl || null;
|
|
79
|
+
const relayHealth = opts.relayHealth || null;
|
|
80
|
+
const sendBufferSnapshotForTest =
|
|
81
|
+
typeof opts.sendBufferSnapshotForTest === "function"
|
|
82
|
+
? opts.sendBufferSnapshotForTest
|
|
83
|
+
: null;
|
|
84
|
+
const getPluginVersion =
|
|
85
|
+
typeof opts.getPluginVersion === "function" ? opts.getPluginVersion : null;
|
|
86
|
+
const getRequiresClientVersion =
|
|
87
|
+
typeof opts.getRequiresClientVersion === "function" ? opts.getRequiresClientVersion : null;
|
|
88
|
+
const pluginId =
|
|
89
|
+
typeof opts.pluginId === "string" && opts.pluginId.trim().length > 0
|
|
90
|
+
? opts.pluginId.trim()
|
|
91
|
+
: null;
|
|
92
|
+
const runPluginUpdate =
|
|
93
|
+
typeof opts.runPluginUpdate === "function" ? opts.runPluginUpdate : null;
|
|
94
|
+
const runGatewayRestart =
|
|
95
|
+
typeof opts.runGatewayRestart === "function" ? opts.runGatewayRestart : null;
|
|
96
|
+
const sessionService =
|
|
97
|
+
opts.sessionService && typeof opts.sessionService === "object"
|
|
98
|
+
? opts.sessionService
|
|
99
|
+
: null;
|
|
67
100
|
const protocolHelloTimeoutMs = Number.isFinite(opts.protocolHelloTimeoutMs)
|
|
68
101
|
? Math.max(0, Math.floor(opts.protocolHelloTimeoutMs))
|
|
69
|
-
:
|
|
102
|
+
: 10000;
|
|
70
103
|
const externalDebugToolsEnabled = opts.externalDebugToolsEnabled !== false;
|
|
71
104
|
const resumeHandshakeTimeoutMs = Number.isFinite(opts.resumeHandshakeTimeoutMs)
|
|
72
105
|
? Math.max(0, Math.floor(opts.resumeHandshakeTimeoutMs))
|
|
73
|
-
:
|
|
74
|
-
const nudgeActiveIntervalMs = Number.isFinite(opts.nudgeActiveIntervalMs)
|
|
75
|
-
? Math.max(1, Math.floor(opts.nudgeActiveIntervalMs))
|
|
76
|
-
: 150;
|
|
77
|
-
const nudgeSlowIntervalMs = Number.isFinite(opts.nudgeSlowIntervalMs)
|
|
78
|
-
? Math.max(1, Math.floor(opts.nudgeSlowIntervalMs))
|
|
79
|
-
: 1000;
|
|
80
|
-
const nudgeIdleDeactivateMs = Number.isFinite(opts.nudgeIdleDeactivateMs)
|
|
81
|
-
? Math.max(0, Math.floor(opts.nudgeIdleDeactivateMs))
|
|
82
|
-
: 5000;
|
|
83
|
-
const nudgeHeartbeatIntervalMs = Number.isFinite(opts.nudgeHeartbeatIntervalMs)
|
|
84
|
-
? Math.max(1, Math.floor(opts.nudgeHeartbeatIntervalMs))
|
|
85
|
-
: 10000;
|
|
86
|
-
const nudgeHardTimeoutMs = Number.isFinite(opts.nudgeHardTimeoutMs)
|
|
87
|
-
? Math.max(1, Math.floor(opts.nudgeHardTimeoutMs))
|
|
88
|
-
: 60000;
|
|
89
|
-
const nudgeStaleHeartbeatThresholdMs = nudgeHeartbeatIntervalMs * 2;
|
|
90
|
-
const RENDER_NUDGE_FRAME = JSON.stringify({ type: "render_nudge" });
|
|
91
|
-
|
|
106
|
+
: 1500;
|
|
92
107
|
/** @type {Map<string, WebSocket>} */
|
|
93
108
|
const clients = new Map();
|
|
94
109
|
/** @type {Map<string, {selectedVersion: "v2"|null, supportedProtocolVersions: string[], clientName: string|null, clientVersion: string|null, sessionKey: string|null, reason: string|null}>} */
|
|
95
110
|
const protocolState = new Map();
|
|
96
111
|
/** @type {Map<string, {pagesSynced: boolean, statusSynced: boolean, approvalsSynced: boolean, debugConfigSynced: boolean, resumeReceived: boolean, timer: any}>} */
|
|
97
112
|
const syncState = new Map();
|
|
98
|
-
/** @type {Map<string, {
|
|
99
|
-
const
|
|
113
|
+
/** @type {Map<string, {clientDebugEnabled: boolean|null, runtimeDiagnosticsVisible: boolean|null, perfNoisyDebugMuted: boolean|null, perfPayloadLiteMode: boolean|null, activeSessionKey: string|null, bundleIdentity: {kind: string|null, mode: string|null, host: string|null, port: number|null, servedDistPath: string|null}|null, runtimeContext: string|null, glasses: {connected: boolean, batteryPercent: number|null, statusAgeMs: number|null}|null, emittedAtMs: number|null, updatedAtMs: number}>} */
|
|
114
|
+
const clientReadinessSnapshotState = new Map();
|
|
115
|
+
/** @type {Map<string, {requesterClientId: string, targetClientId: string, createdAtMs: number}>} */
|
|
116
|
+
const pendingReadinessProbeRequests = new Map();
|
|
117
|
+
/** @type {Map<string, {requesterClientId: string, targetClientId: string, createdAtMs: number, expiryTimer: any}>} */
|
|
118
|
+
const pendingAutomationStateRequests = new Map();
|
|
100
119
|
/** @type {Map<string, string>} */
|
|
101
120
|
const unresolvedApprovals = new Map();
|
|
121
|
+
const nudgeController = createRelayClientNudgeController({
|
|
122
|
+
thresholds: {
|
|
123
|
+
nudgeActiveIntervalMs: opts.nudgeActiveIntervalMs,
|
|
124
|
+
nudgeSlowIntervalMs: opts.nudgeSlowIntervalMs,
|
|
125
|
+
nudgeIdleDeactivateMs: opts.nudgeIdleDeactivateMs,
|
|
126
|
+
nudgeHeartbeatIntervalMs: opts.nudgeHeartbeatIntervalMs,
|
|
127
|
+
nudgeHardTimeoutMs: opts.nudgeHardTimeoutMs,
|
|
128
|
+
},
|
|
129
|
+
isAppClient,
|
|
130
|
+
sendFrame(clientId, frame) {
|
|
131
|
+
const ws = clients.get(clientId);
|
|
132
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
133
|
+
sendMessageToClient(clientId, ws, frame);
|
|
134
|
+
return true;
|
|
135
|
+
},
|
|
136
|
+
});
|
|
102
137
|
let nextClientId = 1;
|
|
138
|
+
let pendingHandlerPromiseCount = 0;
|
|
103
139
|
const APP_PROTOCOL = {
|
|
104
140
|
approvalRequest: "ocuclaw.approval.request",
|
|
105
141
|
approvalResolved: "ocuclaw.approval.resolved",
|
|
142
|
+
automationStateGet: "ocuclaw.automation.state.get",
|
|
143
|
+
automationStateSnapshot: "ocuclaw.automation.state.snapshot",
|
|
144
|
+
avatarBlob: "ocuclaw.avatar.blob",
|
|
145
|
+
avatarFetch: "ocuclaw.avatar.fetch",
|
|
106
146
|
debugConfigSnapshot: "ocuclaw.debug.config.snapshot",
|
|
107
147
|
pages: "ocuclaw.view.pages.snapshot",
|
|
148
|
+
readinessProbeAck: "ocuclaw.readiness.probe.ack",
|
|
149
|
+
readinessProbeRequest: "ocuclaw.readiness.probe.request",
|
|
150
|
+
readinessSnapshot: "ocuclaw.readiness.snapshot",
|
|
108
151
|
resume: "ocuclaw.sync.resume",
|
|
109
152
|
resumeAck: "ocuclaw.sync.resume.ack",
|
|
153
|
+
restartGateway: "ocuclaw.restartGateway",
|
|
154
|
+
restartGatewayAck: "ocuclaw.restartGatewayAck",
|
|
110
155
|
status: "ocuclaw.runtime.status",
|
|
156
|
+
updatePlugin: "ocuclaw.updatePlugin",
|
|
157
|
+
updatePluginResult: "ocuclaw.updatePluginResult",
|
|
111
158
|
};
|
|
112
159
|
const REMOVED_V1_APP_TYPES = new Set([
|
|
113
160
|
"approvalResponse",
|
|
@@ -149,7 +196,10 @@ function createDownstreamServer(opts) {
|
|
|
149
196
|
}
|
|
150
197
|
|
|
151
198
|
const nextRole =
|
|
152
|
-
messageType === "remote-control"
|
|
199
|
+
messageType === "remote-control" ||
|
|
200
|
+
messageType === APP_PROTOCOL.automationStateGet
|
|
201
|
+
? "control"
|
|
202
|
+
: "app";
|
|
153
203
|
if (meta.role === "unknown") {
|
|
154
204
|
meta.role = nextRole;
|
|
155
205
|
return;
|
|
@@ -159,13 +209,6 @@ function createDownstreamServer(opts) {
|
|
|
159
209
|
}
|
|
160
210
|
}
|
|
161
211
|
|
|
162
|
-
function parseRevision(value) {
|
|
163
|
-
if (!Number.isFinite(Number(value))) return null;
|
|
164
|
-
const num = Math.floor(Number(value));
|
|
165
|
-
if (num < 0) return null;
|
|
166
|
-
return num;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
212
|
function parseBool(value) {
|
|
170
213
|
if (value === true || value === false) return value;
|
|
171
214
|
if (typeof value === "number") return value !== 0;
|
|
@@ -174,6 +217,20 @@ function createDownstreamServer(opts) {
|
|
|
174
217
|
return normalized === "true" || normalized === "1" || normalized === "yes";
|
|
175
218
|
}
|
|
176
219
|
|
|
220
|
+
function parseOptionalBoolean(value) {
|
|
221
|
+
if (value === true || value === false) return value;
|
|
222
|
+
if (typeof value === "number") return value !== 0;
|
|
223
|
+
if (typeof value !== "string") return null;
|
|
224
|
+
const normalized = value.trim().toLowerCase();
|
|
225
|
+
if (normalized === "true" || normalized === "1" || normalized === "yes") {
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
if (normalized === "false" || normalized === "0" || normalized === "no") {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
177
234
|
function parseOptionalTrimmedString(value) {
|
|
178
235
|
if (typeof value !== "string") return null;
|
|
179
236
|
const trimmed = value.trim();
|
|
@@ -262,6 +319,134 @@ function createDownstreamServer(opts) {
|
|
|
262
319
|
parseOptionalTrimmedString(value.clientId),
|
|
263
320
|
clientVersion: parseOptionalTrimmedString(value.clientVersion),
|
|
264
321
|
sessionKey: parseOptionalTrimmedString(value.sessionKey),
|
|
322
|
+
readinessSnapshot: parseReadinessSnapshot(value.readinessSnapshot),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function parseReadinessBundleIdentity(value) {
|
|
327
|
+
if (!value || typeof value !== "object") {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
const kind = parseOptionalTrimmedString(value.kind || value.lane);
|
|
331
|
+
const mode = parseOptionalTrimmedString(value.mode);
|
|
332
|
+
const host = parseOptionalTrimmedString(value.host);
|
|
333
|
+
const port = parseOptionalNonNegativeInt(value.port);
|
|
334
|
+
const servedDistPath =
|
|
335
|
+
parseOptionalTrimmedString(value.servedDistPath) ||
|
|
336
|
+
parseOptionalTrimmedString(value.staticDir);
|
|
337
|
+
if (!kind && !mode && !host && port === null && !servedDistPath) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
kind: kind || null,
|
|
342
|
+
mode: mode || null,
|
|
343
|
+
host: host || null,
|
|
344
|
+
port,
|
|
345
|
+
servedDistPath: servedDistPath || null,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function parseReadinessRuntimeContext(value) {
|
|
350
|
+
const s = parseOptionalTrimmedString(value);
|
|
351
|
+
return s === "native_app" || s === "simulator" || s === "browser" ? s : null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function parseReadinessGlasses(value) {
|
|
355
|
+
if (!value || typeof value !== "object") {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
const connected = parseOptionalBoolean(value.connected);
|
|
359
|
+
if (connected === null) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
let batteryPercent = parseOptionalNonNegativeInt(value.batteryPercent);
|
|
363
|
+
if (batteryPercent !== null) {
|
|
364
|
+
batteryPercent = Math.min(100, batteryPercent);
|
|
365
|
+
}
|
|
366
|
+
const statusAgeMs = parseOptionalNonNegativeInt(value.statusAgeMs);
|
|
367
|
+
return { connected, batteryPercent, statusAgeMs };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function parseReadinessSnapshot(value) {
|
|
371
|
+
if (!value || typeof value !== "object") {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
const clientDebugEnabled = parseOptionalBoolean(value.clientDebugEnabled);
|
|
375
|
+
const runtimeDiagnosticsVisible = parseOptionalBoolean(
|
|
376
|
+
value.runtimeDiagnosticsVisible,
|
|
377
|
+
);
|
|
378
|
+
const perfNoisyDebugMuted = parseOptionalBoolean(value.perfNoisyDebugMuted);
|
|
379
|
+
const perfPayloadLiteMode = parseOptionalBoolean(value.perfPayloadLiteMode);
|
|
380
|
+
const activeSessionKey =
|
|
381
|
+
parseOptionalTrimmedString(value.activeSessionKey) ||
|
|
382
|
+
parseOptionalTrimmedString(value.sessionKey);
|
|
383
|
+
const bundleIdentity = parseReadinessBundleIdentity(
|
|
384
|
+
value.bundleIdentity || value.bundle,
|
|
385
|
+
);
|
|
386
|
+
const runtimeContext = parseReadinessRuntimeContext(value.runtimeContext);
|
|
387
|
+
const glasses = parseReadinessGlasses(value.glasses);
|
|
388
|
+
const emittedAtMs = parseOptionalNonNegativeInt(value.emittedAtMs);
|
|
389
|
+
if (
|
|
390
|
+
clientDebugEnabled === null &&
|
|
391
|
+
runtimeDiagnosticsVisible === null &&
|
|
392
|
+
perfNoisyDebugMuted === null &&
|
|
393
|
+
perfPayloadLiteMode === null &&
|
|
394
|
+
!activeSessionKey &&
|
|
395
|
+
!bundleIdentity &&
|
|
396
|
+
!runtimeContext &&
|
|
397
|
+
!glasses &&
|
|
398
|
+
emittedAtMs === null
|
|
399
|
+
) {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
clientDebugEnabled,
|
|
404
|
+
runtimeDiagnosticsVisible,
|
|
405
|
+
perfNoisyDebugMuted,
|
|
406
|
+
perfPayloadLiteMode,
|
|
407
|
+
activeSessionKey: activeSessionKey || null,
|
|
408
|
+
bundleIdentity,
|
|
409
|
+
runtimeContext,
|
|
410
|
+
glasses,
|
|
411
|
+
emittedAtMs,
|
|
412
|
+
updatedAtMs: Date.now(),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function parseReadinessProbeAck(value) {
|
|
417
|
+
if (!value || value.type !== APP_PROTOCOL.readinessProbeAck) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
const requestId = parseOptionalTrimmedString(value.requestId);
|
|
421
|
+
if (!requestId) {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
requestId,
|
|
426
|
+
ok: value.ok !== false,
|
|
427
|
+
reasonCode: parseOptionalTrimmedString(value.reasonCode),
|
|
428
|
+
message: parseOptionalTrimmedString(value.message),
|
|
429
|
+
activeSessionKey:
|
|
430
|
+
parseOptionalTrimmedString(value.activeSessionKey) ||
|
|
431
|
+
parseOptionalTrimmedString(value.sessionKey),
|
|
432
|
+
emittedAtMs: parseOptionalNonNegativeInt(value.emittedAtMs),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function parseAutomationStateSnapshot(value) {
|
|
437
|
+
if (!value || value.type !== APP_PROTOCOL.automationStateSnapshot) {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
const requestId = parseOptionalTrimmedString(value.requestId);
|
|
441
|
+
if (!requestId) {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
requestId,
|
|
446
|
+
ok: value.ok !== false,
|
|
447
|
+
state: value.state && typeof value.state === "object" ? value.state : null,
|
|
448
|
+
reasonCode: parseOptionalTrimmedString(value.reasonCode),
|
|
449
|
+
message: parseOptionalTrimmedString(value.message),
|
|
265
450
|
};
|
|
266
451
|
}
|
|
267
452
|
|
|
@@ -292,7 +477,7 @@ function createDownstreamServer(opts) {
|
|
|
292
477
|
return classifyClientKindFromName(clientName);
|
|
293
478
|
}
|
|
294
479
|
|
|
295
|
-
function countConnectedAppClients(excludeClientId = null) {
|
|
480
|
+
function countConnectedAppClients(excludeClientId = null, sessionKey = null) {
|
|
296
481
|
let count = 0;
|
|
297
482
|
for (const [clientId, ws] of clients) {
|
|
298
483
|
if (excludeClientId && clientId === excludeClientId) {
|
|
@@ -301,7 +486,14 @@ function createDownstreamServer(opts) {
|
|
|
301
486
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
302
487
|
continue;
|
|
303
488
|
}
|
|
304
|
-
|
|
489
|
+
const protocol = protocolState.get(clientId);
|
|
490
|
+
if (classifyClientKind(protocol) !== "app") {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
// When sessionKey is provided, only count app clients bound to that
|
|
494
|
+
// session — lets callers ask "is this session's last app gone?"
|
|
495
|
+
// without being masked by apps in other sessions.
|
|
496
|
+
if (sessionKey != null && (!protocol || protocol.sessionKey !== sessionKey)) {
|
|
305
497
|
continue;
|
|
306
498
|
}
|
|
307
499
|
count += 1;
|
|
@@ -349,27 +541,16 @@ function createDownstreamServer(opts) {
|
|
|
349
541
|
};
|
|
350
542
|
}
|
|
351
543
|
|
|
352
|
-
function interactionStageBucket(stage) {
|
|
353
|
-
switch (stage) {
|
|
354
|
-
case "listening":
|
|
355
|
-
case "voice_handoff":
|
|
356
|
-
case "thinking":
|
|
357
|
-
return "active_non_stream";
|
|
358
|
-
case "streaming":
|
|
359
|
-
case "post_turn_drain":
|
|
360
|
-
return "active_stream";
|
|
361
|
-
default:
|
|
362
|
-
return "idle";
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
544
|
function formatProtocolHelloAck(payload) {
|
|
367
|
-
|
|
368
|
-
|
|
545
|
+
const pluginVersion = getPluginVersion ? getPluginVersion() : null;
|
|
546
|
+
const requiresClientVersion = getRequiresClientVersion ? getRequiresClientVersion() : null;
|
|
547
|
+
return RelayWorkerProtocol.formatProtocolHelloAck({
|
|
369
548
|
protocolVersion: payload.protocolVersion,
|
|
370
549
|
supportedProtocolVersions: payload.supportedProtocolVersions || ["v2"],
|
|
371
|
-
reason: payload.reason
|
|
372
|
-
|
|
550
|
+
reason: payload.reason,
|
|
551
|
+
pluginVersion,
|
|
552
|
+
requiresClientVersion,
|
|
553
|
+
pluginId,
|
|
373
554
|
});
|
|
374
555
|
}
|
|
375
556
|
|
|
@@ -389,436 +570,125 @@ function createDownstreamServer(opts) {
|
|
|
389
570
|
return state;
|
|
390
571
|
}
|
|
391
572
|
|
|
392
|
-
function
|
|
573
|
+
function cloneReadinessBundleIdentity(value) {
|
|
574
|
+
if (!value) return null;
|
|
393
575
|
return {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
cadenceBucket: "idle",
|
|
400
|
-
nudgeActive: false,
|
|
401
|
-
nudgeIntervalMs: null,
|
|
402
|
-
nudgeStartedAtMs: null,
|
|
403
|
-
lastNudgeAtMs: null,
|
|
404
|
-
stalledHeartbeatCount: 0,
|
|
405
|
-
nudgeTimer: null,
|
|
406
|
-
idleDeactivateTimer: null,
|
|
407
|
-
staleHeartbeatTimer: null,
|
|
408
|
-
hardTimeoutTimer: null,
|
|
576
|
+
kind: value.kind || null,
|
|
577
|
+
mode: value.mode || null,
|
|
578
|
+
host: value.host || null,
|
|
579
|
+
port: Number.isFinite(value.port) ? value.port : null,
|
|
580
|
+
servedDistPath: value.servedDistPath || null,
|
|
409
581
|
};
|
|
410
582
|
}
|
|
411
583
|
|
|
412
|
-
function
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
584
|
+
function cloneReadinessGlasses(value) {
|
|
585
|
+
if (!value) return null;
|
|
586
|
+
// The dedicated ocuclaw.readiness.snapshot frame is stored raw via clone
|
|
587
|
+
// (no parseReadinessGlasses pass), so clamp/validate defensively here too.
|
|
588
|
+
const batteryPercent = Number.isFinite(value.batteryPercent)
|
|
589
|
+
? Math.min(100, Math.max(0, value.batteryPercent))
|
|
590
|
+
: null;
|
|
591
|
+
const statusAgeMs =
|
|
592
|
+
Number.isFinite(value.statusAgeMs) && value.statusAgeMs >= 0
|
|
593
|
+
? value.statusAgeMs
|
|
594
|
+
: null;
|
|
423
595
|
return {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
? state.lastHeartbeatAtMs
|
|
428
|
-
: null,
|
|
429
|
-
lastRelayStreamingActivityAtMs: Number.isFinite(
|
|
430
|
-
state.lastRelayStreamingActivityAtMs,
|
|
431
|
-
)
|
|
432
|
-
? state.lastRelayStreamingActivityAtMs
|
|
433
|
-
: null,
|
|
434
|
-
interactionStage: state.interactionStage || "idle",
|
|
435
|
-
cadenceBucket: state.cadenceBucket || "idle",
|
|
436
|
-
nudgeActive: !!state.nudgeActive,
|
|
437
|
-
nudgeIntervalMs: Number.isFinite(state.nudgeIntervalMs)
|
|
438
|
-
? state.nudgeIntervalMs
|
|
439
|
-
: null,
|
|
440
|
-
nudgeStartedAtMs: Number.isFinite(state.nudgeStartedAtMs)
|
|
441
|
-
? state.nudgeStartedAtMs
|
|
442
|
-
: null,
|
|
443
|
-
lastNudgeAtMs: Number.isFinite(state.lastNudgeAtMs)
|
|
444
|
-
? state.lastNudgeAtMs
|
|
445
|
-
: null,
|
|
446
|
-
stalledHeartbeatCount: Number.isFinite(state.stalledHeartbeatCount)
|
|
447
|
-
? state.stalledHeartbeatCount
|
|
448
|
-
: 0,
|
|
596
|
+
connected: value.connected === true,
|
|
597
|
+
batteryPercent,
|
|
598
|
+
statusAgeMs,
|
|
449
599
|
};
|
|
450
600
|
}
|
|
451
601
|
|
|
452
|
-
function
|
|
453
|
-
if (!
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
function isListenEndedBroadcastType(messageType) {
|
|
487
|
-
return messageType === "listen-ended";
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function resetClientStallTracking(state) {
|
|
491
|
-
if (!state) return;
|
|
492
|
-
state.stalledHeartbeatCount = 0;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
function hasStaleHeartbeat(state, now = Date.now()) {
|
|
496
|
-
if (!state || !Number.isFinite(state.lastHeartbeatAtMs)) {
|
|
497
|
-
return false;
|
|
498
|
-
}
|
|
499
|
-
return now - state.lastHeartbeatAtMs >= nudgeStaleHeartbeatThresholdMs;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
function isVisibilityDegraded(state) {
|
|
503
|
-
return (
|
|
504
|
-
!!state &&
|
|
505
|
-
(state.visibilityState === "hidden" || state.visibilityState === "blurred")
|
|
506
|
-
);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
function sendRenderNudge(clientId) {
|
|
510
|
-
const ws = clients.get(clientId);
|
|
511
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
512
|
-
stopClientNudges(clientId, "socket_unavailable");
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
sendMessageToClient(clientId, ws, RENDER_NUDGE_FRAME);
|
|
516
|
-
ensureClientNudgeState(clientId).lastNudgeAtMs = Date.now();
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
function scheduleClientHardTimeout(clientId) {
|
|
520
|
-
const state = clientNudgeState.get(clientId);
|
|
521
|
-
if (!state) return;
|
|
522
|
-
clearClientNudgeTimer(state, "hardTimeoutTimer");
|
|
523
|
-
if (!state.nudgeActive) return;
|
|
524
|
-
state.hardTimeoutTimer = setTimeout(() => {
|
|
525
|
-
state.hardTimeoutTimer = null;
|
|
526
|
-
setClientInteractionStage(clientId, "idle", {
|
|
527
|
-
reason: "nudge_hard_timeout",
|
|
528
|
-
deactivateImmediately: true,
|
|
529
|
-
});
|
|
530
|
-
}, nudgeHardTimeoutMs);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
function startClientNudges(
|
|
534
|
-
clientId,
|
|
535
|
-
intervalMs,
|
|
536
|
-
reason = "nudge_start",
|
|
537
|
-
sendImmediately = false,
|
|
538
|
-
) {
|
|
539
|
-
if (!isAppClient(clientId)) return;
|
|
540
|
-
const ws = clients.get(clientId);
|
|
541
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
542
|
-
const state = ensureClientNudgeState(clientId);
|
|
543
|
-
const nextIntervalMs = Math.max(1, Math.floor(intervalMs));
|
|
544
|
-
const wasActive = !!state.nudgeActive;
|
|
545
|
-
const intervalChanged = state.nudgeIntervalMs !== nextIntervalMs;
|
|
546
|
-
state.cadenceBucket = interactionStageBucket(state.interactionStage);
|
|
547
|
-
if (!wasActive) {
|
|
548
|
-
state.nudgeActive = true;
|
|
549
|
-
state.nudgeStartedAtMs = Date.now();
|
|
550
|
-
}
|
|
551
|
-
if (wasActive && !intervalChanged) {
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
|
|
555
|
-
state.nudgeActive = true;
|
|
556
|
-
state.nudgeIntervalMs = nextIntervalMs;
|
|
557
|
-
state.nudgeTimer = setInterval(() => {
|
|
558
|
-
sendRenderNudge(clientId);
|
|
559
|
-
}, nextIntervalMs);
|
|
560
|
-
if (!wasActive) {
|
|
561
|
-
scheduleClientHardTimeout(clientId);
|
|
562
|
-
if (sendImmediately) {
|
|
563
|
-
sendRenderNudge(clientId);
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
function stopClientNudges(clientId, _reason = "nudge_stop") {
|
|
569
|
-
const state = clientNudgeState.get(clientId);
|
|
570
|
-
if (!state) return;
|
|
571
|
-
clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
|
|
572
|
-
clearClientNudgeTimer(state, "idleDeactivateTimer");
|
|
573
|
-
clearClientNudgeTimer(state, "hardTimeoutTimer");
|
|
574
|
-
state.nudgeActive = false;
|
|
575
|
-
state.nudgeIntervalMs = null;
|
|
576
|
-
state.nudgeStartedAtMs = null;
|
|
577
|
-
resetClientStallTracking(state);
|
|
578
|
-
scheduleClientStaleHeartbeatCheck(clientId);
|
|
602
|
+
function cloneReadinessSnapshot(value) {
|
|
603
|
+
if (!value) return null;
|
|
604
|
+
return {
|
|
605
|
+
clientDebugEnabled:
|
|
606
|
+
value.clientDebugEnabled === true || value.clientDebugEnabled === false
|
|
607
|
+
? value.clientDebugEnabled
|
|
608
|
+
: null,
|
|
609
|
+
runtimeDiagnosticsVisible:
|
|
610
|
+
value.runtimeDiagnosticsVisible === true ||
|
|
611
|
+
value.runtimeDiagnosticsVisible === false
|
|
612
|
+
? value.runtimeDiagnosticsVisible
|
|
613
|
+
: null,
|
|
614
|
+
perfNoisyDebugMuted:
|
|
615
|
+
value.perfNoisyDebugMuted === true || value.perfNoisyDebugMuted === false
|
|
616
|
+
? value.perfNoisyDebugMuted
|
|
617
|
+
: null,
|
|
618
|
+
perfPayloadLiteMode:
|
|
619
|
+
value.perfPayloadLiteMode === true || value.perfPayloadLiteMode === false
|
|
620
|
+
? value.perfPayloadLiteMode
|
|
621
|
+
: null,
|
|
622
|
+
activeSessionKey: value.activeSessionKey || null,
|
|
623
|
+
bundleIdentity: cloneReadinessBundleIdentity(value.bundleIdentity),
|
|
624
|
+
runtimeContext:
|
|
625
|
+
value.runtimeContext === "native_app" ||
|
|
626
|
+
value.runtimeContext === "simulator" ||
|
|
627
|
+
value.runtimeContext === "browser"
|
|
628
|
+
? value.runtimeContext
|
|
629
|
+
: null,
|
|
630
|
+
glasses: cloneReadinessGlasses(value.glasses),
|
|
631
|
+
emittedAtMs: Number.isFinite(value.emittedAtMs) ? value.emittedAtMs : null,
|
|
632
|
+
updatedAtMs: Number.isFinite(value.updatedAtMs) ? value.updatedAtMs : null,
|
|
633
|
+
};
|
|
579
634
|
}
|
|
580
635
|
|
|
581
|
-
function
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
state.idleDeactivateTimer = null;
|
|
590
|
-
const currentState = clientNudgeState.get(clientId);
|
|
591
|
-
if (!currentState || currentState.interactionStage !== "idle") {
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
594
|
-
stopClientNudges(clientId, "idle_grace_elapsed");
|
|
595
|
-
}, nudgeIdleDeactivateMs);
|
|
636
|
+
function updateClientReadinessSnapshot(clientId, snapshot) {
|
|
637
|
+
if (!clientId || !snapshot) return null;
|
|
638
|
+
const next = cloneReadinessSnapshot({
|
|
639
|
+
...snapshot,
|
|
640
|
+
updatedAtMs: Date.now(),
|
|
641
|
+
});
|
|
642
|
+
clientReadinessSnapshotState.set(clientId, next);
|
|
643
|
+
return next;
|
|
596
644
|
}
|
|
597
645
|
|
|
598
|
-
function
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
if (
|
|
603
|
-
state.nudgeActive ||
|
|
604
|
-
interactionStageBucket(state.interactionStage) === "idle" ||
|
|
605
|
-
!Number.isFinite(state.lastHeartbeatAtMs)
|
|
606
|
-
) {
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
const delayMs = Math.max(
|
|
610
|
-
0,
|
|
611
|
-
(state.lastHeartbeatAtMs + nudgeStaleHeartbeatThresholdMs) - Date.now(),
|
|
612
|
-
);
|
|
613
|
-
state.staleHeartbeatTimer = setTimeout(() => {
|
|
614
|
-
state.staleHeartbeatTimer = null;
|
|
615
|
-
const currentState = clientNudgeState.get(clientId);
|
|
646
|
+
function clearPendingReadinessProbesForClient(clientId) {
|
|
647
|
+
if (!clientId) return;
|
|
648
|
+
for (const [requestId, pending] of pendingReadinessProbeRequests) {
|
|
649
|
+
if (!pending) continue;
|
|
616
650
|
if (
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
interactionStageBucket(currentState.interactionStage) === "idle"
|
|
651
|
+
pending.requesterClientId === clientId ||
|
|
652
|
+
pending.targetClientId === clientId
|
|
620
653
|
) {
|
|
621
|
-
|
|
622
|
-
}
|
|
623
|
-
if (!hasStaleHeartbeat(currentState)) {
|
|
624
|
-
scheduleClientStaleHeartbeatCheck(clientId);
|
|
625
|
-
return;
|
|
626
|
-
}
|
|
627
|
-
startClientNudges(
|
|
628
|
-
clientId,
|
|
629
|
-
nudgeActiveIntervalMs,
|
|
630
|
-
"stale_heartbeat_fallback",
|
|
631
|
-
true,
|
|
632
|
-
);
|
|
633
|
-
}, delayMs);
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
function maybeActivateClientNudges(clientId, reason = "nudge_eval") {
|
|
637
|
-
const state = clientNudgeState.get(clientId);
|
|
638
|
-
if (!state || !isAppClient(clientId)) return;
|
|
639
|
-
state.cadenceBucket = interactionStageBucket(state.interactionStage);
|
|
640
|
-
if (state.cadenceBucket === "idle") {
|
|
641
|
-
clearClientNudgeTimer(state, "staleHeartbeatTimer");
|
|
642
|
-
return;
|
|
643
|
-
}
|
|
644
|
-
if (state.nudgeActive) {
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
if (isVisibilityDegraded(state) || hasStaleHeartbeat(state)) {
|
|
648
|
-
startClientNudges(clientId, nudgeActiveIntervalMs, reason, true);
|
|
649
|
-
return;
|
|
650
|
-
}
|
|
651
|
-
scheduleClientStaleHeartbeatCheck(clientId);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
function setClientInteractionStage(clientId, nextStage, options = {}) {
|
|
655
|
-
if (!isAppClient(clientId)) return;
|
|
656
|
-
const state = ensureClientNudgeState(clientId);
|
|
657
|
-
const reason = options.reason || "interaction_stage";
|
|
658
|
-
const deactivateImmediately = options.deactivateImmediately === true;
|
|
659
|
-
state.interactionStage = nextStage;
|
|
660
|
-
state.cadenceBucket = interactionStageBucket(nextStage);
|
|
661
|
-
if (state.cadenceBucket !== "active_stream") {
|
|
662
|
-
resetClientStallTracking(state);
|
|
663
|
-
}
|
|
664
|
-
if (state.cadenceBucket === "idle") {
|
|
665
|
-
clearClientNudgeTimer(state, "staleHeartbeatTimer");
|
|
666
|
-
if (deactivateImmediately) {
|
|
667
|
-
stopClientNudges(clientId, reason);
|
|
668
|
-
} else {
|
|
669
|
-
scheduleClientIdleDeactivation(clientId);
|
|
654
|
+
pendingReadinessProbeRequests.delete(requestId);
|
|
670
655
|
}
|
|
671
|
-
return;
|
|
672
656
|
}
|
|
673
|
-
clearClientNudgeTimer(state, "idleDeactivateTimer");
|
|
674
|
-
if (
|
|
675
|
-
state.cadenceBucket === "active_non_stream" &&
|
|
676
|
-
state.nudgeActive &&
|
|
677
|
-
state.nudgeIntervalMs !== nudgeActiveIntervalMs
|
|
678
|
-
) {
|
|
679
|
-
startClientNudges(clientId, nudgeActiveIntervalMs, reason, false);
|
|
680
|
-
}
|
|
681
|
-
maybeActivateClientNudges(clientId, reason);
|
|
682
657
|
}
|
|
683
658
|
|
|
684
|
-
function
|
|
685
|
-
if (!
|
|
686
|
-
const
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
function updateClientNudgeHeartbeat(clientId, ping) {
|
|
697
|
-
const state = ensureClientNudgeState(clientId);
|
|
698
|
-
const previousHeartbeatAtMs = Number.isFinite(state.lastHeartbeatAtMs)
|
|
699
|
-
? state.lastHeartbeatAtMs
|
|
700
|
-
: null;
|
|
701
|
-
const previousStreamChars = Number.isFinite(state.streamChars)
|
|
702
|
-
? state.streamChars
|
|
703
|
-
: null;
|
|
704
|
-
const nextStreamChars = Number.isFinite(ping.streamChars) ? ping.streamChars : null;
|
|
705
|
-
const streamAdvanced =
|
|
706
|
-
nextStreamChars !== null &&
|
|
707
|
-
(previousStreamChars === null || nextStreamChars > previousStreamChars);
|
|
708
|
-
const relayStreamAdvanced =
|
|
709
|
-
previousHeartbeatAtMs !== null &&
|
|
710
|
-
Number.isFinite(state.lastRelayStreamingActivityAtMs) &&
|
|
711
|
-
state.lastRelayStreamingActivityAtMs > previousHeartbeatAtMs;
|
|
712
|
-
state.streamChars = nextStreamChars;
|
|
713
|
-
if (ping.visibilityState) {
|
|
714
|
-
state.visibilityState = ping.visibilityState;
|
|
715
|
-
}
|
|
716
|
-
state.lastHeartbeatAtMs = Date.now();
|
|
717
|
-
state.cadenceBucket = interactionStageBucket(state.interactionStage);
|
|
718
|
-
if (state.visibilityState === "visible") {
|
|
719
|
-
stopClientNudges(clientId, "heartbeat_visible");
|
|
720
|
-
}
|
|
721
|
-
if (state.cadenceBucket === "active_stream") {
|
|
722
|
-
if (streamAdvanced || relayStreamAdvanced) {
|
|
723
|
-
resetClientStallTracking(state);
|
|
724
|
-
if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
|
|
725
|
-
startClientNudges(
|
|
726
|
-
clientId,
|
|
727
|
-
nudgeActiveIntervalMs,
|
|
728
|
-
streamAdvanced
|
|
729
|
-
? "heartbeat_stream_progress"
|
|
730
|
-
: "relay_stream_progress",
|
|
731
|
-
false,
|
|
732
|
-
);
|
|
733
|
-
}
|
|
734
|
-
} else if (state.nudgeActive) {
|
|
735
|
-
state.stalledHeartbeatCount += 1;
|
|
736
|
-
if (
|
|
737
|
-
state.stalledHeartbeatCount >= 3 &&
|
|
738
|
-
state.nudgeIntervalMs !== nudgeSlowIntervalMs
|
|
739
|
-
) {
|
|
740
|
-
startClientNudges(
|
|
741
|
-
clientId,
|
|
742
|
-
nudgeSlowIntervalMs,
|
|
743
|
-
"stream_stalled_decelerated",
|
|
744
|
-
false,
|
|
745
|
-
);
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
} else {
|
|
749
|
-
resetClientStallTracking(state);
|
|
750
|
-
if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
|
|
751
|
-
startClientNudges(clientId, nudgeActiveIntervalMs, "non_stream_fast", false);
|
|
659
|
+
function clearPendingAutomationStateRequestsForClient(clientId) {
|
|
660
|
+
if (!clientId) return;
|
|
661
|
+
for (const [requestId, pending] of pendingAutomationStateRequests) {
|
|
662
|
+
if (!pending) continue;
|
|
663
|
+
if (
|
|
664
|
+
pending.requesterClientId === clientId ||
|
|
665
|
+
pending.targetClientId === clientId
|
|
666
|
+
) {
|
|
667
|
+
if (pending.expiryTimer) clearTimeout(pending.expiryTimer);
|
|
668
|
+
pendingAutomationStateRequests.delete(requestId);
|
|
752
669
|
}
|
|
753
670
|
}
|
|
754
|
-
maybeActivateClientNudges(clientId, "heartbeat_update");
|
|
755
671
|
}
|
|
756
672
|
|
|
757
|
-
function
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
673
|
+
function buildReadinessClientEntry(clientId) {
|
|
674
|
+
const protocol = protocolState.get(clientId);
|
|
675
|
+
return {
|
|
676
|
+
clientId,
|
|
677
|
+
clientKind: classifyClientKind(protocol),
|
|
678
|
+
clientName: protocol && protocol.clientName ? protocol.clientName : null,
|
|
679
|
+
clientVersion: protocol && protocol.clientVersion ? protocol.clientVersion : null,
|
|
680
|
+
protocolSessionKey: protocol && protocol.sessionKey ? protocol.sessionKey : null,
|
|
681
|
+
readinessSnapshot: cloneReadinessSnapshot(
|
|
682
|
+
clientReadinessSnapshotState.get(clientId) || null,
|
|
683
|
+
),
|
|
684
|
+
};
|
|
765
685
|
}
|
|
766
686
|
|
|
767
|
-
function
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
if (relayStreamingActivityAtMs !== null) {
|
|
773
|
-
setClientInteractionStage(clientId, "streaming", {
|
|
774
|
-
reason: "relay_streaming",
|
|
775
|
-
});
|
|
776
|
-
observeRelayStreamingActivity(clientId, relayStreamingActivityAtMs);
|
|
777
|
-
continue;
|
|
778
|
-
}
|
|
779
|
-
if (isListenCommittedBroadcastType(messageType)) {
|
|
780
|
-
setClientInteractionStage(clientId, "voice_handoff", {
|
|
781
|
-
reason: "listen_committed",
|
|
782
|
-
});
|
|
783
|
-
continue;
|
|
784
|
-
}
|
|
785
|
-
if (isListenEndedBroadcastType(messageType)) {
|
|
786
|
-
setClientInteractionStage(clientId, "idle", {
|
|
787
|
-
reason: "listen_ended",
|
|
788
|
-
deactivateImmediately: true,
|
|
789
|
-
});
|
|
790
|
-
continue;
|
|
791
|
-
}
|
|
792
|
-
if (isActivityBroadcastType(messageType)) {
|
|
793
|
-
const activityState = parseOptionalTrimmedString(parsed && parsed.state);
|
|
794
|
-
const normalizedActivity = activityState ? activityState.toLowerCase() : null;
|
|
795
|
-
const currentStage = ensureClientNudgeState(clientId).interactionStage;
|
|
796
|
-
if (normalizedActivity === "thinking") {
|
|
797
|
-
setClientInteractionStage(clientId, "thinking", {
|
|
798
|
-
reason: "activity_thinking",
|
|
799
|
-
});
|
|
800
|
-
} else if (normalizedActivity === "idle") {
|
|
801
|
-
if (currentStage === "streaming" || currentStage === "post_turn_drain") {
|
|
802
|
-
setClientInteractionStage(clientId, "post_turn_drain", {
|
|
803
|
-
reason: "activity_idle_stream_drain",
|
|
804
|
-
});
|
|
805
|
-
} else {
|
|
806
|
-
setClientInteractionStage(clientId, "idle", {
|
|
807
|
-
reason: "activity_idle",
|
|
808
|
-
});
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
continue;
|
|
812
|
-
}
|
|
813
|
-
if (isPagesBroadcastType(messageType)) {
|
|
814
|
-
const currentState = ensureClientNudgeState(clientId);
|
|
815
|
-
if (interactionStageBucket(currentState.interactionStage) !== "idle") {
|
|
816
|
-
setClientInteractionStage(clientId, "post_turn_drain", {
|
|
817
|
-
reason: "pages_snapshot",
|
|
818
|
-
});
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
}
|
|
687
|
+
function isStreamingBroadcastType(messageType) {
|
|
688
|
+
return (
|
|
689
|
+
messageType === "streaming" ||
|
|
690
|
+
messageType === "ocuclaw.message.stream.delta"
|
|
691
|
+
);
|
|
822
692
|
}
|
|
823
693
|
|
|
824
694
|
function updateProtocolSessionKey(clientId, value) {
|
|
@@ -881,11 +751,233 @@ function createDownstreamServer(opts) {
|
|
|
881
751
|
}, resumeHandshakeTimeoutMs);
|
|
882
752
|
}
|
|
883
753
|
|
|
884
|
-
function
|
|
754
|
+
function parseMessageTypeForHealth(message) {
|
|
755
|
+
try {
|
|
756
|
+
const parsed = JSON.parse(message);
|
|
757
|
+
return parsed && typeof parsed.type === "string" ? parsed.type : null;
|
|
758
|
+
} catch (_) {
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function sendMessageToClient(clientId, ws, message, knownMessageType) {
|
|
885
764
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
886
765
|
return;
|
|
887
766
|
}
|
|
888
767
|
ws.send(message);
|
|
768
|
+
if (relayHealth && typeof relayHealth.observeSendBuffer === "function") {
|
|
769
|
+
const bufferedAmountBytes = sendBufferSnapshotForTest
|
|
770
|
+
? sendBufferSnapshotForTest(ws)
|
|
771
|
+
: Number.isFinite(ws.bufferedAmount)
|
|
772
|
+
? ws.bufferedAmount
|
|
773
|
+
: null;
|
|
774
|
+
relayHealth.observeSendBuffer({
|
|
775
|
+
clientId,
|
|
776
|
+
messageType:
|
|
777
|
+
knownMessageType !== undefined
|
|
778
|
+
? knownMessageType
|
|
779
|
+
: parseMessageTypeForHealth(message),
|
|
780
|
+
bufferedAmountBytes,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function forwardReadinessProbeAck(clientId, ack) {
|
|
786
|
+
if (!ack || !ack.requestId) {
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const pending = pendingReadinessProbeRequests.get(ack.requestId);
|
|
790
|
+
if (!pending || pending.targetClientId !== clientId) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
pendingReadinessProbeRequests.delete(ack.requestId);
|
|
794
|
+
const requesterWs = clients.get(pending.requesterClientId);
|
|
795
|
+
if (!requesterWs || requesterWs.readyState !== WebSocket.OPEN) {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const protocol = protocolState.get(clientId);
|
|
799
|
+
const message =
|
|
800
|
+
handler && typeof handler.formatReadinessProbeAck === "function"
|
|
801
|
+
? handler.formatReadinessProbeAck({
|
|
802
|
+
ok: ack.ok !== false,
|
|
803
|
+
requestId: ack.requestId,
|
|
804
|
+
reasonCode: ack.reasonCode || null,
|
|
805
|
+
message: ack.message || null,
|
|
806
|
+
activeSessionKey: ack.activeSessionKey || null,
|
|
807
|
+
emittedAtMs: ack.emittedAtMs,
|
|
808
|
+
clientId,
|
|
809
|
+
clientName: protocol && protocol.clientName ? protocol.clientName : null,
|
|
810
|
+
clientVersion:
|
|
811
|
+
protocol && protocol.clientVersion ? protocol.clientVersion : null,
|
|
812
|
+
})
|
|
813
|
+
: JSON.stringify({
|
|
814
|
+
type: APP_PROTOCOL.readinessProbeAck,
|
|
815
|
+
ok: ack.ok !== false,
|
|
816
|
+
requestId: ack.requestId,
|
|
817
|
+
reasonCode: ack.reasonCode || null,
|
|
818
|
+
message: ack.message || null,
|
|
819
|
+
activeSessionKey: ack.activeSessionKey || null,
|
|
820
|
+
emittedAtMs: ack.emittedAtMs,
|
|
821
|
+
clientId,
|
|
822
|
+
clientName: protocol && protocol.clientName ? protocol.clientName : null,
|
|
823
|
+
clientVersion:
|
|
824
|
+
protocol && protocol.clientVersion ? protocol.clientVersion : null,
|
|
825
|
+
});
|
|
826
|
+
sendMessageToClient(pending.requesterClientId, requesterWs, message);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function forwardAutomationStateSnapshot(clientId, snapshot) {
|
|
830
|
+
if (!snapshot || !snapshot.requestId) {
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const pending = pendingAutomationStateRequests.get(snapshot.requestId);
|
|
834
|
+
if (!pending || pending.targetClientId !== clientId) {
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (pending.expiryTimer) clearTimeout(pending.expiryTimer);
|
|
838
|
+
pendingAutomationStateRequests.delete(snapshot.requestId);
|
|
839
|
+
const requesterWs = clients.get(pending.requesterClientId);
|
|
840
|
+
if (!requesterWs || requesterWs.readyState !== WebSocket.OPEN) {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const message =
|
|
844
|
+
handler && typeof handler.formatAutomationStateSnapshot === "function"
|
|
845
|
+
? handler.formatAutomationStateSnapshot({
|
|
846
|
+
ok: snapshot.ok !== false,
|
|
847
|
+
requestId: snapshot.requestId,
|
|
848
|
+
state: snapshot.state || null,
|
|
849
|
+
reasonCode: snapshot.reasonCode || null,
|
|
850
|
+
message: snapshot.message || null,
|
|
851
|
+
})
|
|
852
|
+
: JSON.stringify({
|
|
853
|
+
type: APP_PROTOCOL.automationStateSnapshot,
|
|
854
|
+
ok: snapshot.ok !== false,
|
|
855
|
+
requestId: snapshot.requestId,
|
|
856
|
+
state: snapshot.state || null,
|
|
857
|
+
reasonCode: snapshot.reasonCode || null,
|
|
858
|
+
message: snapshot.message || null,
|
|
859
|
+
});
|
|
860
|
+
sendMessageToClient(pending.requesterClientId, requesterWs, message);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function expirePendingAutomationStateRequest(requestId) {
|
|
864
|
+
const pending = pendingAutomationStateRequests.get(requestId);
|
|
865
|
+
if (!pending) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (pending.expiryTimer) clearTimeout(pending.expiryTimer);
|
|
869
|
+
pendingAutomationStateRequests.delete(requestId);
|
|
870
|
+
const requesterWs = clients.get(pending.requesterClientId);
|
|
871
|
+
if (!requesterWs || requesterWs.readyState !== WebSocket.OPEN) {
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const message =
|
|
875
|
+
handler && typeof handler.formatAutomationStateSnapshot === "function"
|
|
876
|
+
? handler.formatAutomationStateSnapshot({
|
|
877
|
+
ok: false,
|
|
878
|
+
requestId,
|
|
879
|
+
state: null,
|
|
880
|
+
reasonCode: "snapshot_unavailable",
|
|
881
|
+
message: "Automation state snapshot is unavailable",
|
|
882
|
+
})
|
|
883
|
+
: JSON.stringify({
|
|
884
|
+
type: APP_PROTOCOL.automationStateSnapshot,
|
|
885
|
+
ok: false,
|
|
886
|
+
requestId,
|
|
887
|
+
state: null,
|
|
888
|
+
reasonCode: "snapshot_unavailable",
|
|
889
|
+
message: "Automation state snapshot is unavailable",
|
|
890
|
+
});
|
|
891
|
+
sendMessageToClient(pending.requesterClientId, requesterWs, message);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function buildAutomationStateDispatchResult(senderId, parsed) {
|
|
895
|
+
const requestId = parseOptionalTrimmedString(parsed && parsed.requestId);
|
|
896
|
+
const sessionKey = parseOptionalTrimmedString(parsed && parsed.sessionKey);
|
|
897
|
+
const snapshot = getReadinessSnapshot();
|
|
898
|
+
const targetEntry =
|
|
899
|
+
snapshot &&
|
|
900
|
+
snapshot.connectedClientCount === 1 &&
|
|
901
|
+
snapshot.fanoutRecipientCount === 1 &&
|
|
902
|
+
Array.isArray(snapshot.clients) &&
|
|
903
|
+
snapshot.clients.length === 1
|
|
904
|
+
? snapshot.clients[0]
|
|
905
|
+
: null;
|
|
906
|
+
const targetClientId =
|
|
907
|
+
targetEntry && typeof targetEntry.clientId === "string"
|
|
908
|
+
? targetEntry.clientId
|
|
909
|
+
: null;
|
|
910
|
+
const readinessPublished =
|
|
911
|
+
!!(
|
|
912
|
+
targetEntry &&
|
|
913
|
+
targetEntry.readinessSnapshot &&
|
|
914
|
+
Number.isFinite(targetEntry.readinessSnapshot.emittedAtMs)
|
|
915
|
+
);
|
|
916
|
+
|
|
917
|
+
const formatFailure = (reasonCode, message) => ({
|
|
918
|
+
unicast:
|
|
919
|
+
handler && typeof handler.formatAutomationStateSnapshot === "function"
|
|
920
|
+
? handler.formatAutomationStateSnapshot({
|
|
921
|
+
ok: false,
|
|
922
|
+
requestId,
|
|
923
|
+
reasonCode,
|
|
924
|
+
message,
|
|
925
|
+
})
|
|
926
|
+
: JSON.stringify({
|
|
927
|
+
type: APP_PROTOCOL.automationStateSnapshot,
|
|
928
|
+
ok: false,
|
|
929
|
+
requestId,
|
|
930
|
+
reasonCode,
|
|
931
|
+
message,
|
|
932
|
+
}),
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
if (!requestId) {
|
|
936
|
+
return formatFailure(
|
|
937
|
+
"snapshot_unavailable",
|
|
938
|
+
"automation state request requires requestId",
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
if (
|
|
942
|
+
!snapshot ||
|
|
943
|
+
snapshot.connectedClientCount <= 0 ||
|
|
944
|
+
snapshot.fanoutRecipientCount <= 0
|
|
945
|
+
) {
|
|
946
|
+
return formatFailure("no_downstream_client", "No downstream app client connected");
|
|
947
|
+
}
|
|
948
|
+
if (
|
|
949
|
+
snapshot.connectedClientCount > 1 ||
|
|
950
|
+
snapshot.fanoutRecipientCount > 1 ||
|
|
951
|
+
!targetClientId
|
|
952
|
+
) {
|
|
953
|
+
return formatFailure(
|
|
954
|
+
"multi_recipient_fanout",
|
|
955
|
+
"Multiple downstream app clients connected",
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
if (!readinessPublished) {
|
|
959
|
+
return formatFailure(
|
|
960
|
+
"snapshot_unavailable",
|
|
961
|
+
"Automation state snapshot is unavailable",
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
return {
|
|
965
|
+
automationStateRequest: {
|
|
966
|
+
requestId,
|
|
967
|
+
targetClientId,
|
|
968
|
+
message:
|
|
969
|
+
handler && typeof handler.formatAutomationStateRequest === "function"
|
|
970
|
+
? handler.formatAutomationStateRequest({
|
|
971
|
+
requestId,
|
|
972
|
+
sessionKey: sessionKey || null,
|
|
973
|
+
})
|
|
974
|
+
: JSON.stringify({
|
|
975
|
+
type: APP_PROTOCOL.automationStateGet,
|
|
976
|
+
requestId,
|
|
977
|
+
sessionKey: sessionKey || null,
|
|
978
|
+
}),
|
|
979
|
+
},
|
|
980
|
+
};
|
|
889
981
|
}
|
|
890
982
|
|
|
891
983
|
function getServerResumeState() {
|
|
@@ -894,30 +986,17 @@ function createDownstreamServer(opts) {
|
|
|
894
986
|
}
|
|
895
987
|
const state = getCurrentResumeState() || {};
|
|
896
988
|
return {
|
|
897
|
-
pagesRevision:
|
|
898
|
-
|
|
989
|
+
pagesRevision: RelayWorkerProtocol.parseNonNegativeRevision(
|
|
990
|
+
state.pagesRevision,
|
|
991
|
+
),
|
|
992
|
+
statusRevision: RelayWorkerProtocol.parseNonNegativeRevision(
|
|
993
|
+
state.statusRevision,
|
|
994
|
+
),
|
|
899
995
|
};
|
|
900
996
|
}
|
|
901
997
|
|
|
902
998
|
function formatResumeAck(payload) {
|
|
903
|
-
return
|
|
904
|
-
type: APP_PROTOCOL.resumeAck,
|
|
905
|
-
reason: payload.reason || null,
|
|
906
|
-
sentPages: !!payload.sentPages,
|
|
907
|
-
sentStatus: !!payload.sentStatus,
|
|
908
|
-
sentApprovals:
|
|
909
|
-
Number.isFinite(payload.sentApprovals) && payload.sentApprovals > 0
|
|
910
|
-
? Math.floor(payload.sentApprovals)
|
|
911
|
-
: 0,
|
|
912
|
-
pagesRevision:
|
|
913
|
-
payload.pagesRevision === null || payload.pagesRevision === undefined
|
|
914
|
-
? null
|
|
915
|
-
: payload.pagesRevision,
|
|
916
|
-
statusRevision:
|
|
917
|
-
payload.statusRevision === null || payload.statusRevision === undefined
|
|
918
|
-
? null
|
|
919
|
-
: payload.statusRevision,
|
|
920
|
-
});
|
|
999
|
+
return RelayWorkerProtocol.formatResumeAck(payload);
|
|
921
1000
|
}
|
|
922
1001
|
|
|
923
1002
|
function isSnapshotSynced(sync) {
|
|
@@ -971,10 +1050,10 @@ function createDownstreamServer(opts) {
|
|
|
971
1050
|
const status = getCurrentStatus();
|
|
972
1051
|
const debugConfig = getCurrentDebugConfig ? getCurrentDebugConfig() : null;
|
|
973
1052
|
const state = getServerResumeState();
|
|
974
|
-
const clientPagesRevision =
|
|
1053
|
+
const clientPagesRevision = RelayWorkerProtocol.parseNonNegativeRevision(
|
|
975
1054
|
resumeRequest && resumeRequest.pagesRevision,
|
|
976
1055
|
);
|
|
977
|
-
const clientStatusRevision =
|
|
1056
|
+
const clientStatusRevision = RelayWorkerProtocol.parseNonNegativeRevision(
|
|
978
1057
|
resumeRequest && resumeRequest.statusRevision,
|
|
979
1058
|
);
|
|
980
1059
|
const clientHasPagesState = parseBool(
|
|
@@ -1059,12 +1138,6 @@ function createDownstreamServer(opts) {
|
|
|
1059
1138
|
if (opts.httpServer) {
|
|
1060
1139
|
wss = new WebSocketServer({ noServer: true });
|
|
1061
1140
|
opts.httpServer.on("upgrade", (req, socket, head) => {
|
|
1062
|
-
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1063
|
-
if (url.searchParams.get("token") !== token) {
|
|
1064
|
-
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
1065
|
-
socket.destroy();
|
|
1066
|
-
return;
|
|
1067
|
-
}
|
|
1068
1141
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1069
1142
|
wss.emit("connection", ws, req);
|
|
1070
1143
|
});
|
|
@@ -1073,14 +1146,15 @@ function createDownstreamServer(opts) {
|
|
|
1073
1146
|
wss = new WebSocketServer({
|
|
1074
1147
|
host: opts.host,
|
|
1075
1148
|
port: opts.port,
|
|
1076
|
-
verifyClient: ({ req }) => {
|
|
1077
|
-
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1078
|
-
return url.searchParams.get("token") === token;
|
|
1079
|
-
},
|
|
1080
1149
|
});
|
|
1081
1150
|
}
|
|
1082
1151
|
|
|
1083
1152
|
wss.on("connection", (ws, req) => {
|
|
1153
|
+
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
|
|
1154
|
+
if (requestUrl.searchParams.get("token") !== token) {
|
|
1155
|
+
ws.close(4001, "invalid_token");
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1084
1158
|
const clientId = `client-${nextClientId++}`;
|
|
1085
1159
|
clients.set(clientId, ws);
|
|
1086
1160
|
const connectedAtMs = Date.now();
|
|
@@ -1097,7 +1171,7 @@ function createDownstreamServer(opts) {
|
|
|
1097
1171
|
connectedEventEmitted: false,
|
|
1098
1172
|
};
|
|
1099
1173
|
ensureProtocolState(clientId);
|
|
1100
|
-
|
|
1174
|
+
nudgeController.addClient(clientId);
|
|
1101
1175
|
const currentPages = getCurrentPages();
|
|
1102
1176
|
const currentStatus = getCurrentStatus();
|
|
1103
1177
|
syncState.set(clientId, {
|
|
@@ -1156,7 +1230,7 @@ function createDownstreamServer(opts) {
|
|
|
1156
1230
|
|
|
1157
1231
|
// Intercept application-level ping — respond with pong, skip handler
|
|
1158
1232
|
if (enrichedPing) {
|
|
1159
|
-
|
|
1233
|
+
nudgeController.updateHeartbeat(clientId, enrichedPing);
|
|
1160
1234
|
ws.send(JSON.stringify({ type: "pong", ts: parsed.ts }));
|
|
1161
1235
|
return;
|
|
1162
1236
|
}
|
|
@@ -1191,6 +1265,9 @@ function createDownstreamServer(opts) {
|
|
|
1191
1265
|
clientVersion: protocolHello.clientVersion,
|
|
1192
1266
|
sessionKey: protocolHello.sessionKey,
|
|
1193
1267
|
});
|
|
1268
|
+
if (protocolHello.readinessSnapshot) {
|
|
1269
|
+
updateClientReadinessSnapshot(clientId, protocolHello.readinessSnapshot);
|
|
1270
|
+
}
|
|
1194
1271
|
logger.info(
|
|
1195
1272
|
`[downstream] ${clientId} identified protocol=${state.selectedVersion || "n/a"} client=${describeProtocolClient(state)} kind=${classifyClientKind(state)} session=${state.sessionKey || "n/a"}`,
|
|
1196
1273
|
);
|
|
@@ -1244,6 +1321,29 @@ function createDownstreamServer(opts) {
|
|
|
1244
1321
|
return;
|
|
1245
1322
|
}
|
|
1246
1323
|
|
|
1324
|
+
if (parsed && parsed.type === APP_PROTOCOL.readinessSnapshot) {
|
|
1325
|
+
if (classifyClientKind(state) === "app") {
|
|
1326
|
+
updateClientReadinessSnapshot(clientId, parsed);
|
|
1327
|
+
}
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const readinessProbeAck = parseReadinessProbeAck(parsed);
|
|
1332
|
+
if (readinessProbeAck) {
|
|
1333
|
+
if (classifyClientKind(state) === "app") {
|
|
1334
|
+
forwardReadinessProbeAck(clientId, readinessProbeAck);
|
|
1335
|
+
}
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const automationStateSnapshot = parseAutomationStateSnapshot(parsed);
|
|
1340
|
+
if (automationStateSnapshot) {
|
|
1341
|
+
if (classifyClientKind(state) === "app") {
|
|
1342
|
+
forwardAutomationStateSnapshot(clientId, automationStateSnapshot);
|
|
1343
|
+
}
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1247
1347
|
if (parsed && parsed.type === APP_PROTOCOL.resume) {
|
|
1248
1348
|
const sync = syncState.get(clientId);
|
|
1249
1349
|
if (sync) sync.resumeReceived = true;
|
|
@@ -1254,15 +1354,25 @@ function createDownstreamServer(opts) {
|
|
|
1254
1354
|
return;
|
|
1255
1355
|
}
|
|
1256
1356
|
|
|
1357
|
+
if (parsed && parsed.type === APP_PROTOCOL.automationStateGet) {
|
|
1358
|
+
const result = handler.handleMessage(clientId, raw);
|
|
1359
|
+
if (result !== null && result !== undefined) {
|
|
1360
|
+
processResult(clientId, result);
|
|
1361
|
+
} else {
|
|
1362
|
+
processResult(clientId, buildAutomationStateDispatchResult(clientId, parsed));
|
|
1363
|
+
}
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1257
1367
|
const visibilityControl = parseVisibilityControl(parsed);
|
|
1258
1368
|
const drainCompleteControl = parseDrainCompleteControl(parsed);
|
|
1259
1369
|
const transportControl = visibilityControl || drainCompleteControl;
|
|
1260
1370
|
if (transportControl) {
|
|
1261
1371
|
const protocol = protocolState.get(clientId);
|
|
1262
1372
|
if (transportControl.type === "visibility") {
|
|
1263
|
-
|
|
1373
|
+
nudgeController.updateVisibilityState(clientId, transportControl.state);
|
|
1264
1374
|
} else {
|
|
1265
|
-
|
|
1375
|
+
nudgeController.setInteractionStage(clientId, "idle", {
|
|
1266
1376
|
reason: "drain_complete",
|
|
1267
1377
|
deactivateImmediately: true,
|
|
1268
1378
|
});
|
|
@@ -1295,6 +1405,229 @@ function createDownstreamServer(opts) {
|
|
|
1295
1405
|
return;
|
|
1296
1406
|
}
|
|
1297
1407
|
|
|
1408
|
+
if (parsed && parsed.type === APP_PROTOCOL.updatePlugin) {
|
|
1409
|
+
if (!runPluginUpdate) {
|
|
1410
|
+
logger.warn(
|
|
1411
|
+
`[downstream] ${clientId} updatePlugin requested but no runPluginUpdate is configured`,
|
|
1412
|
+
);
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
const requestId =
|
|
1416
|
+
parseOptionalTrimmedString(parsed.requestId) || null;
|
|
1417
|
+
logger.info(
|
|
1418
|
+
`[downstream] ${clientId} updatePlugin requested requestId=${requestId || "n/a"}`,
|
|
1419
|
+
);
|
|
1420
|
+
runPluginUpdate()
|
|
1421
|
+
.then((result) => {
|
|
1422
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1423
|
+
const payload = { type: APP_PROTOCOL.updatePluginResult };
|
|
1424
|
+
if (requestId) payload.requestId = requestId;
|
|
1425
|
+
if (result && result.ok === true) {
|
|
1426
|
+
payload.ok = true;
|
|
1427
|
+
} else {
|
|
1428
|
+
payload.ok = false;
|
|
1429
|
+
if (result && typeof result.reason === "string") {
|
|
1430
|
+
payload.reason = result.reason;
|
|
1431
|
+
}
|
|
1432
|
+
if (result && typeof result.exitCode === "number") {
|
|
1433
|
+
payload.exitCode = result.exitCode;
|
|
1434
|
+
}
|
|
1435
|
+
if (result && typeof result.stderrTail === "string" && result.stderrTail.length > 0) {
|
|
1436
|
+
payload.stderrTail = result.stderrTail;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
sendMessageToClient(clientId, ws, JSON.stringify(payload));
|
|
1440
|
+
})
|
|
1441
|
+
.catch((err) => {
|
|
1442
|
+
logger.error(
|
|
1443
|
+
`[downstream] ${clientId} updatePlugin threw: ${err && err.message ? err.message : err}`,
|
|
1444
|
+
);
|
|
1445
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1446
|
+
const payload = {
|
|
1447
|
+
type: APP_PROTOCOL.updatePluginResult,
|
|
1448
|
+
ok: false,
|
|
1449
|
+
reason: "spawn_failed",
|
|
1450
|
+
};
|
|
1451
|
+
if (requestId) payload.requestId = requestId;
|
|
1452
|
+
sendMessageToClient(clientId, ws, JSON.stringify(payload));
|
|
1453
|
+
});
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
if (parsed && parsed.type === APP_PROTOCOL.restartGateway) {
|
|
1458
|
+
if (!runGatewayRestart) {
|
|
1459
|
+
logger.warn(
|
|
1460
|
+
`[downstream] ${clientId} restartGateway requested but no runGatewayRestart is configured`,
|
|
1461
|
+
);
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
const requestId =
|
|
1465
|
+
parseOptionalTrimmedString(parsed.requestId) || null;
|
|
1466
|
+
logger.info(
|
|
1467
|
+
`[downstream] ${clientId} restartGateway requested requestId=${requestId || "n/a"}`,
|
|
1468
|
+
);
|
|
1469
|
+
runGatewayRestart()
|
|
1470
|
+
.then((result) => {
|
|
1471
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1472
|
+
const payload = {
|
|
1473
|
+
type: APP_PROTOCOL.restartGatewayAck,
|
|
1474
|
+
ok: !!(result && result.ok),
|
|
1475
|
+
started: !!(result && result.started),
|
|
1476
|
+
};
|
|
1477
|
+
if (requestId) payload.requestId = requestId;
|
|
1478
|
+
if (result && typeof result.reason === "string") {
|
|
1479
|
+
payload.reason = result.reason;
|
|
1480
|
+
}
|
|
1481
|
+
sendMessageToClient(clientId, ws, JSON.stringify(payload));
|
|
1482
|
+
})
|
|
1483
|
+
.catch((err) => {
|
|
1484
|
+
logger.error(
|
|
1485
|
+
`[downstream] ${clientId} restartGateway threw: ${err && err.message ? err.message : err}`,
|
|
1486
|
+
);
|
|
1487
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1488
|
+
const payload = {
|
|
1489
|
+
type: APP_PROTOCOL.restartGatewayAck,
|
|
1490
|
+
ok: false,
|
|
1491
|
+
started: false,
|
|
1492
|
+
reason: "spawn_failed",
|
|
1493
|
+
};
|
|
1494
|
+
if (requestId) payload.requestId = requestId;
|
|
1495
|
+
sendMessageToClient(clientId, ws, JSON.stringify(payload));
|
|
1496
|
+
});
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
if (parsed && parsed.type === "ocuclaw.session.pinned.set") {
|
|
1501
|
+
if (!sessionService) {
|
|
1502
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1503
|
+
sendMessageToClient(
|
|
1504
|
+
clientId,
|
|
1505
|
+
ws,
|
|
1506
|
+
JSON.stringify({ type: "error", error: "session_service_unavailable" }),
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
const sessionKey = typeof parsed.sessionKey === "string" ? parsed.sessionKey : "";
|
|
1512
|
+
const pinned = parsed.pinned;
|
|
1513
|
+
const kind = parsed.kind;
|
|
1514
|
+
if (
|
|
1515
|
+
!sessionKey ||
|
|
1516
|
+
typeof pinned !== "boolean" ||
|
|
1517
|
+
(kind !== "ocuclaw" && kind !== "evenai")
|
|
1518
|
+
) {
|
|
1519
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1520
|
+
sendMessageToClient(
|
|
1521
|
+
clientId,
|
|
1522
|
+
ws,
|
|
1523
|
+
JSON.stringify({ type: "error", error: "invalid_session_pin_request" }),
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
const result = sessionService.setSessionPinned(kind, sessionKey, pinned);
|
|
1529
|
+
if (!result.ok && result.reason === "cap") {
|
|
1530
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1531
|
+
sendMessageToClient(
|
|
1532
|
+
clientId,
|
|
1533
|
+
ws,
|
|
1534
|
+
JSON.stringify({ type: "error", error: "pin_cap_reached" }),
|
|
1535
|
+
);
|
|
1536
|
+
}
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
if (!result.ok) {
|
|
1540
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1541
|
+
sendMessageToClient(
|
|
1542
|
+
clientId,
|
|
1543
|
+
ws,
|
|
1544
|
+
JSON.stringify({ type: "error", error: "invalid_session_pin_request" }),
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
if (typeof sessionService.broadcastSessionsForKind === "function") {
|
|
1550
|
+
Promise.resolve(sessionService.broadcastSessionsForKind(kind)).catch((err) => {
|
|
1551
|
+
logger.error(
|
|
1552
|
+
`[downstream] broadcastSessionsForKind failed: ${err && err.message ? err.message : err}`,
|
|
1553
|
+
);
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
if (parsed && parsed.type === "ocuclaw.session.delete") {
|
|
1560
|
+
if (!sessionService) {
|
|
1561
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1562
|
+
sendMessageToClient(
|
|
1563
|
+
clientId,
|
|
1564
|
+
ws,
|
|
1565
|
+
JSON.stringify({ type: "error", error: "session_service_unavailable" }),
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
const sessionKeys = Array.isArray(parsed.sessionKeys) ? parsed.sessionKeys : null;
|
|
1571
|
+
const kind = parsed.kind;
|
|
1572
|
+
const switchBeforeDelete = parsed.switchBeforeDelete === true;
|
|
1573
|
+
if (
|
|
1574
|
+
!sessionKeys ||
|
|
1575
|
+
sessionKeys.length === 0 ||
|
|
1576
|
+
sessionKeys.some((k) => typeof k !== "string" || !k) ||
|
|
1577
|
+
(kind !== "ocuclaw" && kind !== "evenai")
|
|
1578
|
+
) {
|
|
1579
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1580
|
+
sendMessageToClient(
|
|
1581
|
+
clientId,
|
|
1582
|
+
ws,
|
|
1583
|
+
JSON.stringify({ type: "error", error: "invalid_session_delete_request" }),
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
const action = switchBeforeDelete
|
|
1589
|
+
? sessionService.switchAndDeleteSessions(kind, sessionKeys)
|
|
1590
|
+
: sessionService.deleteSessions(kind, sessionKeys);
|
|
1591
|
+
Promise.resolve(action)
|
|
1592
|
+
.then(() => {
|
|
1593
|
+
if (typeof sessionService.broadcastSessionsForKind === "function") {
|
|
1594
|
+
return sessionService.broadcastSessionsForKind(kind);
|
|
1595
|
+
}
|
|
1596
|
+
return null;
|
|
1597
|
+
})
|
|
1598
|
+
.catch((err) => {
|
|
1599
|
+
logger.error(
|
|
1600
|
+
`[downstream] session.delete pipeline failed: ${err && err.message ? err.message : err}`,
|
|
1601
|
+
);
|
|
1602
|
+
});
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
if (parsed && parsed.type === APP_PROTOCOL.avatarFetch) {
|
|
1607
|
+
if (state.selectedVersion !== "v2") return;
|
|
1608
|
+
const requestedAgentName =
|
|
1609
|
+
typeof parsed.agentName === "string" ? parsed.agentName : null;
|
|
1610
|
+
const requestedHash =
|
|
1611
|
+
typeof parsed.hash === "string" && /^[0-9a-f]{64}$/.test(parsed.hash)
|
|
1612
|
+
? parsed.hash
|
|
1613
|
+
: null;
|
|
1614
|
+
if (!requestedAgentName || !requestedHash) {
|
|
1615
|
+
// Malformed request — silently drop. The WebUI guards against this.
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
const dataUri = getAgentAvatarDataUriByHash(requestedHash);
|
|
1619
|
+
const payload = {
|
|
1620
|
+
type: APP_PROTOCOL.avatarBlob,
|
|
1621
|
+
agentName: requestedAgentName,
|
|
1622
|
+
hash: requestedHash,
|
|
1623
|
+
dataUri: dataUri || null,
|
|
1624
|
+
};
|
|
1625
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1626
|
+
sendMessageToClient(clientId, ws, JSON.stringify(payload));
|
|
1627
|
+
}
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1298
1631
|
const result = handler.handleMessage(clientId, raw);
|
|
1299
1632
|
processResult(clientId, result);
|
|
1300
1633
|
});
|
|
@@ -1307,8 +1640,10 @@ function createDownstreamServer(opts) {
|
|
|
1307
1640
|
const protocol = protocolState.get(clientId);
|
|
1308
1641
|
syncState.delete(clientId);
|
|
1309
1642
|
protocolState.delete(clientId);
|
|
1310
|
-
|
|
1311
|
-
|
|
1643
|
+
clientReadinessSnapshotState.delete(clientId);
|
|
1644
|
+
clearPendingReadinessProbesForClient(clientId);
|
|
1645
|
+
clearPendingAutomationStateRequestsForClient(clientId);
|
|
1646
|
+
nudgeController.deleteClient(clientId);
|
|
1312
1647
|
handler.removeClient(clientId);
|
|
1313
1648
|
clients.delete(clientId);
|
|
1314
1649
|
const lifetimeMs = Date.now() - connectedAtMs;
|
|
@@ -1354,15 +1689,36 @@ function createDownstreamServer(opts) {
|
|
|
1354
1689
|
* @param {string} senderId - Client ID that sent the original message
|
|
1355
1690
|
* @param {object|null|Promise} result - Handler return value
|
|
1356
1691
|
*/
|
|
1692
|
+
function sendUnicastResult(senderId, unicast) {
|
|
1693
|
+
const frames = Array.isArray(unicast) ? unicast : [unicast];
|
|
1694
|
+
const ws = clients.get(senderId);
|
|
1695
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
1696
|
+
for (const frame of frames) {
|
|
1697
|
+
if (typeof frame === "string") {
|
|
1698
|
+
sendMessageToClient(senderId, ws, frame);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1357
1703
|
function processResult(senderId, result) {
|
|
1358
1704
|
if (result === null || result === undefined) {
|
|
1359
1705
|
return;
|
|
1360
1706
|
}
|
|
1361
1707
|
|
|
1362
1708
|
if (typeof result.then === "function") {
|
|
1709
|
+
pendingHandlerPromiseCount += 1;
|
|
1710
|
+
if (relayHealth && typeof relayHealth.emitQueueDepth === "function") {
|
|
1711
|
+
relayHealth.emitQueueDepth({
|
|
1712
|
+
pendingHandlerPromises: pendingHandlerPromiseCount,
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1363
1715
|
result.then(
|
|
1364
|
-
(resolved) =>
|
|
1716
|
+
(resolved) => {
|
|
1717
|
+
pendingHandlerPromiseCount = Math.max(0, pendingHandlerPromiseCount - 1);
|
|
1718
|
+
processResult(senderId, resolved);
|
|
1719
|
+
},
|
|
1365
1720
|
(err) => {
|
|
1721
|
+
pendingHandlerPromiseCount = Math.max(0, pendingHandlerPromiseCount - 1);
|
|
1366
1722
|
logger.error(
|
|
1367
1723
|
`[downstream] Error processing message from ${senderId}:`,
|
|
1368
1724
|
err,
|
|
@@ -1373,9 +1729,110 @@ function createDownstreamServer(opts) {
|
|
|
1373
1729
|
}
|
|
1374
1730
|
|
|
1375
1731
|
if (result.unicast) {
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1732
|
+
sendUnicastResult(senderId, result.unicast);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
if (result.followup && typeof result.followup.then === "function") {
|
|
1736
|
+
processResult(senderId, result.followup);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
if (result.readinessProbe) {
|
|
1740
|
+
const requestId = parseOptionalTrimmedString(
|
|
1741
|
+
result.readinessProbe.requestId,
|
|
1742
|
+
);
|
|
1743
|
+
const targetClientId = parseOptionalTrimmedString(
|
|
1744
|
+
result.readinessProbe.targetClientId,
|
|
1745
|
+
);
|
|
1746
|
+
const message =
|
|
1747
|
+
typeof result.readinessProbe.message === "string"
|
|
1748
|
+
? result.readinessProbe.message
|
|
1749
|
+
: null;
|
|
1750
|
+
const targetWs = targetClientId ? clients.get(targetClientId) : null;
|
|
1751
|
+
if (
|
|
1752
|
+
!requestId ||
|
|
1753
|
+
!targetClientId ||
|
|
1754
|
+
!message ||
|
|
1755
|
+
!targetWs ||
|
|
1756
|
+
targetWs.readyState !== WebSocket.OPEN ||
|
|
1757
|
+
!isAppClient(targetClientId)
|
|
1758
|
+
) {
|
|
1759
|
+
const requesterWs = clients.get(senderId);
|
|
1760
|
+
if (
|
|
1761
|
+
requesterWs &&
|
|
1762
|
+
requesterWs.readyState === WebSocket.OPEN &&
|
|
1763
|
+
handler &&
|
|
1764
|
+
typeof handler.formatReadinessProbeAck === "function"
|
|
1765
|
+
) {
|
|
1766
|
+
sendMessageToClient(
|
|
1767
|
+
senderId,
|
|
1768
|
+
requesterWs,
|
|
1769
|
+
handler.formatReadinessProbeAck({
|
|
1770
|
+
ok: false,
|
|
1771
|
+
requestId,
|
|
1772
|
+
reasonCode: "no_downstream_client",
|
|
1773
|
+
message: "No downstream app client connected",
|
|
1774
|
+
}),
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
} else {
|
|
1778
|
+
pendingReadinessProbeRequests.set(requestId, {
|
|
1779
|
+
requesterClientId: senderId,
|
|
1780
|
+
targetClientId,
|
|
1781
|
+
createdAtMs: Date.now(),
|
|
1782
|
+
});
|
|
1783
|
+
sendMessageToClient(targetClientId, targetWs, message);
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
if (result.automationStateRequest) {
|
|
1788
|
+
const requestId = parseOptionalTrimmedString(
|
|
1789
|
+
result.automationStateRequest.requestId,
|
|
1790
|
+
);
|
|
1791
|
+
const targetClientId = parseOptionalTrimmedString(
|
|
1792
|
+
result.automationStateRequest.targetClientId,
|
|
1793
|
+
);
|
|
1794
|
+
const message =
|
|
1795
|
+
typeof result.automationStateRequest.message === "string"
|
|
1796
|
+
? result.automationStateRequest.message
|
|
1797
|
+
: null;
|
|
1798
|
+
const targetWs = targetClientId ? clients.get(targetClientId) : null;
|
|
1799
|
+
if (
|
|
1800
|
+
!requestId ||
|
|
1801
|
+
!targetClientId ||
|
|
1802
|
+
!message ||
|
|
1803
|
+
!targetWs ||
|
|
1804
|
+
targetWs.readyState !== WebSocket.OPEN ||
|
|
1805
|
+
!isAppClient(targetClientId)
|
|
1806
|
+
) {
|
|
1807
|
+
const requesterWs = clients.get(senderId);
|
|
1808
|
+
if (
|
|
1809
|
+
requesterWs &&
|
|
1810
|
+
requesterWs.readyState === WebSocket.OPEN &&
|
|
1811
|
+
handler &&
|
|
1812
|
+
typeof handler.formatAutomationStateSnapshot === "function"
|
|
1813
|
+
) {
|
|
1814
|
+
sendMessageToClient(
|
|
1815
|
+
senderId,
|
|
1816
|
+
requesterWs,
|
|
1817
|
+
handler.formatAutomationStateSnapshot({
|
|
1818
|
+
ok: false,
|
|
1819
|
+
requestId,
|
|
1820
|
+
reasonCode: "snapshot_unavailable",
|
|
1821
|
+
message: "Automation state snapshot is unavailable",
|
|
1822
|
+
}),
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1825
|
+
} else {
|
|
1826
|
+
pendingAutomationStateRequests.set(requestId, {
|
|
1827
|
+
requesterClientId: senderId,
|
|
1828
|
+
targetClientId,
|
|
1829
|
+
createdAtMs: Date.now(),
|
|
1830
|
+
expiryTimer: setTimeout(
|
|
1831
|
+
() => expirePendingAutomationStateRequest(requestId),
|
|
1832
|
+
AUTOMATION_STATE_REPLY_TIMEOUT_MS,
|
|
1833
|
+
),
|
|
1834
|
+
});
|
|
1835
|
+
sendMessageToClient(targetClientId, targetWs, message);
|
|
1379
1836
|
}
|
|
1380
1837
|
}
|
|
1381
1838
|
|
|
@@ -1439,11 +1896,7 @@ function createDownstreamServer(opts) {
|
|
|
1439
1896
|
: null;
|
|
1440
1897
|
for (const [clientId, ws] of clients) {
|
|
1441
1898
|
if (ws.readyState === WebSocket.OPEN) {
|
|
1442
|
-
sendMessageToClient(clientId, ws, message);
|
|
1443
|
-
if (relayStreamingActivityAtMs !== null) {
|
|
1444
|
-
ensureClientNudgeState(clientId).lastRelayStreamingActivityAtMs =
|
|
1445
|
-
relayStreamingActivityAtMs;
|
|
1446
|
-
}
|
|
1899
|
+
sendMessageToClient(clientId, ws, message, messageType);
|
|
1447
1900
|
if (
|
|
1448
1901
|
messageType === "pages" ||
|
|
1449
1902
|
messageType === "status" ||
|
|
@@ -1454,7 +1907,11 @@ function createDownstreamServer(opts) {
|
|
|
1454
1907
|
}
|
|
1455
1908
|
}
|
|
1456
1909
|
}
|
|
1457
|
-
applyBroadcastInteractionStage(
|
|
1910
|
+
nudgeController.applyBroadcastInteractionStage(
|
|
1911
|
+
messageType,
|
|
1912
|
+
parsed,
|
|
1913
|
+
relayStreamingActivityAtMs,
|
|
1914
|
+
);
|
|
1458
1915
|
}
|
|
1459
1916
|
|
|
1460
1917
|
/**
|
|
@@ -1480,7 +1937,7 @@ function createDownstreamServer(opts) {
|
|
|
1480
1937
|
if (ws.readyState !== WebSocket.OPEN) {
|
|
1481
1938
|
continue;
|
|
1482
1939
|
}
|
|
1483
|
-
sendMessageToClient(clientId, ws, message);
|
|
1940
|
+
sendMessageToClient(clientId, ws, message, messageType);
|
|
1484
1941
|
if (
|
|
1485
1942
|
messageType === "pages" ||
|
|
1486
1943
|
messageType === "status" ||
|
|
@@ -1515,8 +1972,39 @@ function createDownstreamServer(opts) {
|
|
|
1515
1972
|
return Array.from(clients.keys());
|
|
1516
1973
|
}
|
|
1517
1974
|
|
|
1518
|
-
function getConnectedAppCount(excludeClientId = null) {
|
|
1519
|
-
return countConnectedAppClients(excludeClientId);
|
|
1975
|
+
function getConnectedAppCount(excludeClientId = null, sessionKey = null) {
|
|
1976
|
+
return countConnectedAppClients(excludeClientId, sessionKey);
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
function getReadinessSnapshot() {
|
|
1980
|
+
const clientsOut = [];
|
|
1981
|
+
let latestUpdatedAtMs = null;
|
|
1982
|
+
for (const [clientId, ws] of clients) {
|
|
1983
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
1984
|
+
continue;
|
|
1985
|
+
}
|
|
1986
|
+
if (!isAppClient(clientId)) {
|
|
1987
|
+
continue;
|
|
1988
|
+
}
|
|
1989
|
+
const entry = buildReadinessClientEntry(clientId);
|
|
1990
|
+
clientsOut.push(entry);
|
|
1991
|
+
const updatedAtMs =
|
|
1992
|
+
entry &&
|
|
1993
|
+
entry.readinessSnapshot &&
|
|
1994
|
+
Number.isFinite(entry.readinessSnapshot.updatedAtMs)
|
|
1995
|
+
? entry.readinessSnapshot.updatedAtMs
|
|
1996
|
+
: null;
|
|
1997
|
+
if (updatedAtMs !== null && (latestUpdatedAtMs === null || updatedAtMs > latestUpdatedAtMs)) {
|
|
1998
|
+
latestUpdatedAtMs = updatedAtMs;
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
clientsOut.sort((left, right) => String(left.clientId).localeCompare(String(right.clientId)));
|
|
2002
|
+
return {
|
|
2003
|
+
connectedClientCount: clientsOut.length,
|
|
2004
|
+
fanoutRecipientCount: clientsOut.length,
|
|
2005
|
+
updatedAtMs: latestUpdatedAtMs,
|
|
2006
|
+
clients: clientsOut,
|
|
2007
|
+
};
|
|
1520
2008
|
}
|
|
1521
2009
|
|
|
1522
2010
|
/**
|
|
@@ -1530,10 +2018,13 @@ function createDownstreamServer(opts) {
|
|
|
1530
2018
|
if (sync.timer) clearTimeout(sync.timer);
|
|
1531
2019
|
}
|
|
1532
2020
|
syncState.clear();
|
|
1533
|
-
|
|
1534
|
-
|
|
2021
|
+
nudgeController.clear();
|
|
2022
|
+
clientReadinessSnapshotState.clear();
|
|
2023
|
+
pendingReadinessProbeRequests.clear();
|
|
2024
|
+
for (const [, pending] of pendingAutomationStateRequests) {
|
|
2025
|
+
if (pending && pending.expiryTimer) clearTimeout(pending.expiryTimer);
|
|
1535
2026
|
}
|
|
1536
|
-
|
|
2027
|
+
pendingAutomationStateRequests.clear();
|
|
1537
2028
|
for (const [, ws] of clients) {
|
|
1538
2029
|
ws.close();
|
|
1539
2030
|
}
|
|
@@ -1547,9 +2038,10 @@ function createDownstreamServer(opts) {
|
|
|
1547
2038
|
unicast,
|
|
1548
2039
|
getClientIds,
|
|
1549
2040
|
getConnectedAppCount,
|
|
2041
|
+
getReadinessSnapshot,
|
|
1550
2042
|
closeConnectedAppClients,
|
|
1551
2043
|
getClientNudgeState(clientId) {
|
|
1552
|
-
return
|
|
2044
|
+
return nudgeController.getClientState(clientId);
|
|
1553
2045
|
},
|
|
1554
2046
|
close,
|
|
1555
2047
|
get httpServer() {
|