ocuclaw 1.2.4 → 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 +18 -5
- 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 +38 -182
- 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 +754 -83
- package/dist/runtime/downstream-server.js +700 -534
- 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 +1209 -204
- 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 +615 -24
- 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,53 +68,81 @@ 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 clientNudgeState = new Map();
|
|
100
|
-
/** @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, emittedAtMs: number|null, updatedAtMs: number}>} */
|
|
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}>} */
|
|
101
114
|
const clientReadinessSnapshotState = new Map();
|
|
102
115
|
/** @type {Map<string, {requesterClientId: string, targetClientId: string, createdAtMs: number}>} */
|
|
103
116
|
const pendingReadinessProbeRequests = new Map();
|
|
117
|
+
/** @type {Map<string, {requesterClientId: string, targetClientId: string, createdAtMs: number, expiryTimer: any}>} */
|
|
118
|
+
const pendingAutomationStateRequests = new Map();
|
|
104
119
|
/** @type {Map<string, string>} */
|
|
105
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
|
+
});
|
|
106
137
|
let nextClientId = 1;
|
|
138
|
+
let pendingHandlerPromiseCount = 0;
|
|
107
139
|
const APP_PROTOCOL = {
|
|
108
140
|
approvalRequest: "ocuclaw.approval.request",
|
|
109
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",
|
|
110
146
|
debugConfigSnapshot: "ocuclaw.debug.config.snapshot",
|
|
111
147
|
pages: "ocuclaw.view.pages.snapshot",
|
|
112
148
|
readinessProbeAck: "ocuclaw.readiness.probe.ack",
|
|
@@ -114,7 +150,11 @@ function createDownstreamServer(opts) {
|
|
|
114
150
|
readinessSnapshot: "ocuclaw.readiness.snapshot",
|
|
115
151
|
resume: "ocuclaw.sync.resume",
|
|
116
152
|
resumeAck: "ocuclaw.sync.resume.ack",
|
|
153
|
+
restartGateway: "ocuclaw.restartGateway",
|
|
154
|
+
restartGatewayAck: "ocuclaw.restartGatewayAck",
|
|
117
155
|
status: "ocuclaw.runtime.status",
|
|
156
|
+
updatePlugin: "ocuclaw.updatePlugin",
|
|
157
|
+
updatePluginResult: "ocuclaw.updatePluginResult",
|
|
118
158
|
};
|
|
119
159
|
const REMOVED_V1_APP_TYPES = new Set([
|
|
120
160
|
"approvalResponse",
|
|
@@ -156,7 +196,10 @@ function createDownstreamServer(opts) {
|
|
|
156
196
|
}
|
|
157
197
|
|
|
158
198
|
const nextRole =
|
|
159
|
-
messageType === "remote-control"
|
|
199
|
+
messageType === "remote-control" ||
|
|
200
|
+
messageType === APP_PROTOCOL.automationStateGet
|
|
201
|
+
? "control"
|
|
202
|
+
: "app";
|
|
160
203
|
if (meta.role === "unknown") {
|
|
161
204
|
meta.role = nextRole;
|
|
162
205
|
return;
|
|
@@ -166,13 +209,6 @@ function createDownstreamServer(opts) {
|
|
|
166
209
|
}
|
|
167
210
|
}
|
|
168
211
|
|
|
169
|
-
function parseRevision(value) {
|
|
170
|
-
if (!Number.isFinite(Number(value))) return null;
|
|
171
|
-
const num = Math.floor(Number(value));
|
|
172
|
-
if (num < 0) return null;
|
|
173
|
-
return num;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
212
|
function parseBool(value) {
|
|
177
213
|
if (value === true || value === false) return value;
|
|
178
214
|
if (typeof value === "number") return value !== 0;
|
|
@@ -310,6 +346,27 @@ function createDownstreamServer(opts) {
|
|
|
310
346
|
};
|
|
311
347
|
}
|
|
312
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
|
+
|
|
313
370
|
function parseReadinessSnapshot(value) {
|
|
314
371
|
if (!value || typeof value !== "object") {
|
|
315
372
|
return null;
|
|
@@ -326,6 +383,8 @@ function createDownstreamServer(opts) {
|
|
|
326
383
|
const bundleIdentity = parseReadinessBundleIdentity(
|
|
327
384
|
value.bundleIdentity || value.bundle,
|
|
328
385
|
);
|
|
386
|
+
const runtimeContext = parseReadinessRuntimeContext(value.runtimeContext);
|
|
387
|
+
const glasses = parseReadinessGlasses(value.glasses);
|
|
329
388
|
const emittedAtMs = parseOptionalNonNegativeInt(value.emittedAtMs);
|
|
330
389
|
if (
|
|
331
390
|
clientDebugEnabled === null &&
|
|
@@ -334,6 +393,8 @@ function createDownstreamServer(opts) {
|
|
|
334
393
|
perfPayloadLiteMode === null &&
|
|
335
394
|
!activeSessionKey &&
|
|
336
395
|
!bundleIdentity &&
|
|
396
|
+
!runtimeContext &&
|
|
397
|
+
!glasses &&
|
|
337
398
|
emittedAtMs === null
|
|
338
399
|
) {
|
|
339
400
|
return null;
|
|
@@ -345,6 +406,8 @@ function createDownstreamServer(opts) {
|
|
|
345
406
|
perfPayloadLiteMode,
|
|
346
407
|
activeSessionKey: activeSessionKey || null,
|
|
347
408
|
bundleIdentity,
|
|
409
|
+
runtimeContext,
|
|
410
|
+
glasses,
|
|
348
411
|
emittedAtMs,
|
|
349
412
|
updatedAtMs: Date.now(),
|
|
350
413
|
};
|
|
@@ -370,6 +433,23 @@ function createDownstreamServer(opts) {
|
|
|
370
433
|
};
|
|
371
434
|
}
|
|
372
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),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
373
453
|
function describeProtocolClient(state) {
|
|
374
454
|
if (!state) return "unknown";
|
|
375
455
|
const clientName = parseOptionalTrimmedString(state.clientName);
|
|
@@ -397,7 +477,7 @@ function createDownstreamServer(opts) {
|
|
|
397
477
|
return classifyClientKindFromName(clientName);
|
|
398
478
|
}
|
|
399
479
|
|
|
400
|
-
function countConnectedAppClients(excludeClientId = null) {
|
|
480
|
+
function countConnectedAppClients(excludeClientId = null, sessionKey = null) {
|
|
401
481
|
let count = 0;
|
|
402
482
|
for (const [clientId, ws] of clients) {
|
|
403
483
|
if (excludeClientId && clientId === excludeClientId) {
|
|
@@ -406,7 +486,14 @@ function createDownstreamServer(opts) {
|
|
|
406
486
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
407
487
|
continue;
|
|
408
488
|
}
|
|
409
|
-
|
|
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)) {
|
|
410
497
|
continue;
|
|
411
498
|
}
|
|
412
499
|
count += 1;
|
|
@@ -454,27 +541,16 @@ function createDownstreamServer(opts) {
|
|
|
454
541
|
};
|
|
455
542
|
}
|
|
456
543
|
|
|
457
|
-
function interactionStageBucket(stage) {
|
|
458
|
-
switch (stage) {
|
|
459
|
-
case "listening":
|
|
460
|
-
case "voice_handoff":
|
|
461
|
-
case "thinking":
|
|
462
|
-
return "active_non_stream";
|
|
463
|
-
case "streaming":
|
|
464
|
-
case "post_turn_drain":
|
|
465
|
-
return "active_stream";
|
|
466
|
-
default:
|
|
467
|
-
return "idle";
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
544
|
function formatProtocolHelloAck(payload) {
|
|
472
|
-
|
|
473
|
-
|
|
545
|
+
const pluginVersion = getPluginVersion ? getPluginVersion() : null;
|
|
546
|
+
const requiresClientVersion = getRequiresClientVersion ? getRequiresClientVersion() : null;
|
|
547
|
+
return RelayWorkerProtocol.formatProtocolHelloAck({
|
|
474
548
|
protocolVersion: payload.protocolVersion,
|
|
475
549
|
supportedProtocolVersions: payload.supportedProtocolVersions || ["v2"],
|
|
476
|
-
reason: payload.reason
|
|
477
|
-
|
|
550
|
+
reason: payload.reason,
|
|
551
|
+
pluginVersion,
|
|
552
|
+
requiresClientVersion,
|
|
553
|
+
pluginId,
|
|
478
554
|
});
|
|
479
555
|
}
|
|
480
556
|
|
|
@@ -494,66 +570,6 @@ function createDownstreamServer(opts) {
|
|
|
494
570
|
return state;
|
|
495
571
|
}
|
|
496
572
|
|
|
497
|
-
function createClientNudgeState() {
|
|
498
|
-
return {
|
|
499
|
-
visibilityState: null,
|
|
500
|
-
streamChars: null,
|
|
501
|
-
lastHeartbeatAtMs: null,
|
|
502
|
-
lastRelayStreamingActivityAtMs: null,
|
|
503
|
-
interactionStage: "idle",
|
|
504
|
-
cadenceBucket: "idle",
|
|
505
|
-
nudgeActive: false,
|
|
506
|
-
nudgeIntervalMs: null,
|
|
507
|
-
nudgeStartedAtMs: null,
|
|
508
|
-
lastNudgeAtMs: null,
|
|
509
|
-
stalledHeartbeatCount: 0,
|
|
510
|
-
nudgeTimer: null,
|
|
511
|
-
idleDeactivateTimer: null,
|
|
512
|
-
staleHeartbeatTimer: null,
|
|
513
|
-
hardTimeoutTimer: null,
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
function ensureClientNudgeState(clientId) {
|
|
518
|
-
let state = clientNudgeState.get(clientId);
|
|
519
|
-
if (!state) {
|
|
520
|
-
state = createClientNudgeState();
|
|
521
|
-
clientNudgeState.set(clientId, state);
|
|
522
|
-
}
|
|
523
|
-
return state;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
function cloneClientNudgeState(state) {
|
|
527
|
-
if (!state) return null;
|
|
528
|
-
return {
|
|
529
|
-
visibilityState: state.visibilityState || null,
|
|
530
|
-
streamChars: Number.isFinite(state.streamChars) ? state.streamChars : null,
|
|
531
|
-
lastHeartbeatAtMs: Number.isFinite(state.lastHeartbeatAtMs)
|
|
532
|
-
? state.lastHeartbeatAtMs
|
|
533
|
-
: null,
|
|
534
|
-
lastRelayStreamingActivityAtMs: Number.isFinite(
|
|
535
|
-
state.lastRelayStreamingActivityAtMs,
|
|
536
|
-
)
|
|
537
|
-
? state.lastRelayStreamingActivityAtMs
|
|
538
|
-
: null,
|
|
539
|
-
interactionStage: state.interactionStage || "idle",
|
|
540
|
-
cadenceBucket: state.cadenceBucket || "idle",
|
|
541
|
-
nudgeActive: !!state.nudgeActive,
|
|
542
|
-
nudgeIntervalMs: Number.isFinite(state.nudgeIntervalMs)
|
|
543
|
-
? state.nudgeIntervalMs
|
|
544
|
-
: null,
|
|
545
|
-
nudgeStartedAtMs: Number.isFinite(state.nudgeStartedAtMs)
|
|
546
|
-
? state.nudgeStartedAtMs
|
|
547
|
-
: null,
|
|
548
|
-
lastNudgeAtMs: Number.isFinite(state.lastNudgeAtMs)
|
|
549
|
-
? state.lastNudgeAtMs
|
|
550
|
-
: null,
|
|
551
|
-
stalledHeartbeatCount: Number.isFinite(state.stalledHeartbeatCount)
|
|
552
|
-
? state.stalledHeartbeatCount
|
|
553
|
-
: 0,
|
|
554
|
-
};
|
|
555
|
-
}
|
|
556
|
-
|
|
557
573
|
function cloneReadinessBundleIdentity(value) {
|
|
558
574
|
if (!value) return null;
|
|
559
575
|
return {
|
|
@@ -565,6 +581,24 @@ function createDownstreamServer(opts) {
|
|
|
565
581
|
};
|
|
566
582
|
}
|
|
567
583
|
|
|
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;
|
|
595
|
+
return {
|
|
596
|
+
connected: value.connected === true,
|
|
597
|
+
batteryPercent,
|
|
598
|
+
statusAgeMs,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
568
602
|
function cloneReadinessSnapshot(value) {
|
|
569
603
|
if (!value) return null;
|
|
570
604
|
return {
|
|
@@ -587,6 +621,13 @@ function createDownstreamServer(opts) {
|
|
|
587
621
|
: null,
|
|
588
622
|
activeSessionKey: value.activeSessionKey || null,
|
|
589
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),
|
|
590
631
|
emittedAtMs: Number.isFinite(value.emittedAtMs) ? value.emittedAtMs : null,
|
|
591
632
|
updatedAtMs: Number.isFinite(value.updatedAtMs) ? value.updatedAtMs : null,
|
|
592
633
|
};
|
|
@@ -615,6 +656,20 @@ function createDownstreamServer(opts) {
|
|
|
615
656
|
}
|
|
616
657
|
}
|
|
617
658
|
|
|
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);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
618
673
|
function buildReadinessClientEntry(clientId) {
|
|
619
674
|
const protocol = protocolState.get(clientId);
|
|
620
675
|
return {
|
|
@@ -629,21 +684,6 @@ function createDownstreamServer(opts) {
|
|
|
629
684
|
};
|
|
630
685
|
}
|
|
631
686
|
|
|
632
|
-
function clearClientNudgeTimer(state, key, clearFn = clearTimeout) {
|
|
633
|
-
if (!state || !state[key]) return;
|
|
634
|
-
clearFn(state[key]);
|
|
635
|
-
state[key] = null;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
function clearClientNudgeTimers(clientId) {
|
|
639
|
-
const state = clientNudgeState.get(clientId);
|
|
640
|
-
if (!state) return;
|
|
641
|
-
clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
|
|
642
|
-
clearClientNudgeTimer(state, "idleDeactivateTimer");
|
|
643
|
-
clearClientNudgeTimer(state, "staleHeartbeatTimer");
|
|
644
|
-
clearClientNudgeTimer(state, "hardTimeoutTimer");
|
|
645
|
-
}
|
|
646
|
-
|
|
647
687
|
function isStreamingBroadcastType(messageType) {
|
|
648
688
|
return (
|
|
649
689
|
messageType === "streaming" ||
|
|
@@ -651,356 +691,6 @@ function createDownstreamServer(opts) {
|
|
|
651
691
|
);
|
|
652
692
|
}
|
|
653
693
|
|
|
654
|
-
function isPagesBroadcastType(messageType) {
|
|
655
|
-
return messageType === "pages" || messageType === APP_PROTOCOL.pages;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
function isActivityBroadcastType(messageType) {
|
|
659
|
-
return messageType === "activity" || messageType === "ocuclaw.activity.update";
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
function isListenCommittedBroadcastType(messageType) {
|
|
663
|
-
return messageType === "listen-committed";
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
function isListenEndedBroadcastType(messageType) {
|
|
667
|
-
return messageType === "listen-ended";
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
function resetClientStallTracking(state) {
|
|
671
|
-
if (!state) return;
|
|
672
|
-
state.stalledHeartbeatCount = 0;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
function hasStaleHeartbeat(state, now = Date.now()) {
|
|
676
|
-
if (!state || !Number.isFinite(state.lastHeartbeatAtMs)) {
|
|
677
|
-
return false;
|
|
678
|
-
}
|
|
679
|
-
return now - state.lastHeartbeatAtMs >= nudgeStaleHeartbeatThresholdMs;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
function isVisibilityDegraded(state) {
|
|
683
|
-
return (
|
|
684
|
-
!!state &&
|
|
685
|
-
(state.visibilityState === "hidden" || state.visibilityState === "blurred")
|
|
686
|
-
);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
function sendRenderNudge(clientId) {
|
|
690
|
-
const ws = clients.get(clientId);
|
|
691
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
692
|
-
stopClientNudges(clientId, "socket_unavailable");
|
|
693
|
-
return;
|
|
694
|
-
}
|
|
695
|
-
sendMessageToClient(clientId, ws, RENDER_NUDGE_FRAME);
|
|
696
|
-
ensureClientNudgeState(clientId).lastNudgeAtMs = Date.now();
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
function scheduleClientHardTimeout(clientId) {
|
|
700
|
-
const state = clientNudgeState.get(clientId);
|
|
701
|
-
if (!state) return;
|
|
702
|
-
clearClientNudgeTimer(state, "hardTimeoutTimer");
|
|
703
|
-
if (!state.nudgeActive) return;
|
|
704
|
-
state.hardTimeoutTimer = setTimeout(() => {
|
|
705
|
-
state.hardTimeoutTimer = null;
|
|
706
|
-
setClientInteractionStage(clientId, "idle", {
|
|
707
|
-
reason: "nudge_hard_timeout",
|
|
708
|
-
deactivateImmediately: true,
|
|
709
|
-
});
|
|
710
|
-
}, nudgeHardTimeoutMs);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
function startClientNudges(
|
|
714
|
-
clientId,
|
|
715
|
-
intervalMs,
|
|
716
|
-
reason = "nudge_start",
|
|
717
|
-
sendImmediately = false,
|
|
718
|
-
) {
|
|
719
|
-
if (!isAppClient(clientId)) return;
|
|
720
|
-
const ws = clients.get(clientId);
|
|
721
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
722
|
-
const state = ensureClientNudgeState(clientId);
|
|
723
|
-
const nextIntervalMs = Math.max(1, Math.floor(intervalMs));
|
|
724
|
-
const wasActive = !!state.nudgeActive;
|
|
725
|
-
const intervalChanged = state.nudgeIntervalMs !== nextIntervalMs;
|
|
726
|
-
state.cadenceBucket = interactionStageBucket(state.interactionStage);
|
|
727
|
-
if (!wasActive) {
|
|
728
|
-
state.nudgeActive = true;
|
|
729
|
-
state.nudgeStartedAtMs = Date.now();
|
|
730
|
-
}
|
|
731
|
-
if (wasActive && !intervalChanged) {
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
|
|
735
|
-
state.nudgeActive = true;
|
|
736
|
-
state.nudgeIntervalMs = nextIntervalMs;
|
|
737
|
-
state.nudgeTimer = setInterval(() => {
|
|
738
|
-
sendRenderNudge(clientId);
|
|
739
|
-
}, nextIntervalMs);
|
|
740
|
-
if (!wasActive) {
|
|
741
|
-
scheduleClientHardTimeout(clientId);
|
|
742
|
-
if (sendImmediately) {
|
|
743
|
-
sendRenderNudge(clientId);
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
function stopClientNudges(clientId, _reason = "nudge_stop") {
|
|
749
|
-
const state = clientNudgeState.get(clientId);
|
|
750
|
-
if (!state) return;
|
|
751
|
-
clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
|
|
752
|
-
clearClientNudgeTimer(state, "idleDeactivateTimer");
|
|
753
|
-
clearClientNudgeTimer(state, "hardTimeoutTimer");
|
|
754
|
-
state.nudgeActive = false;
|
|
755
|
-
state.nudgeIntervalMs = null;
|
|
756
|
-
state.nudgeStartedAtMs = null;
|
|
757
|
-
resetClientStallTracking(state);
|
|
758
|
-
scheduleClientStaleHeartbeatCheck(clientId);
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
function scheduleClientIdleDeactivation(clientId) {
|
|
762
|
-
const state = clientNudgeState.get(clientId);
|
|
763
|
-
if (!state) return;
|
|
764
|
-
clearClientNudgeTimer(state, "idleDeactivateTimer");
|
|
765
|
-
if (!state.nudgeActive || state.interactionStage !== "idle") {
|
|
766
|
-
return;
|
|
767
|
-
}
|
|
768
|
-
state.idleDeactivateTimer = setTimeout(() => {
|
|
769
|
-
state.idleDeactivateTimer = null;
|
|
770
|
-
const currentState = clientNudgeState.get(clientId);
|
|
771
|
-
if (!currentState || currentState.interactionStage !== "idle") {
|
|
772
|
-
return;
|
|
773
|
-
}
|
|
774
|
-
stopClientNudges(clientId, "idle_grace_elapsed");
|
|
775
|
-
}, nudgeIdleDeactivateMs);
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
function scheduleClientStaleHeartbeatCheck(clientId) {
|
|
779
|
-
const state = clientNudgeState.get(clientId);
|
|
780
|
-
if (!state) return;
|
|
781
|
-
clearClientNudgeTimer(state, "staleHeartbeatTimer");
|
|
782
|
-
if (
|
|
783
|
-
state.nudgeActive ||
|
|
784
|
-
interactionStageBucket(state.interactionStage) === "idle" ||
|
|
785
|
-
!Number.isFinite(state.lastHeartbeatAtMs)
|
|
786
|
-
) {
|
|
787
|
-
return;
|
|
788
|
-
}
|
|
789
|
-
const delayMs = Math.max(
|
|
790
|
-
0,
|
|
791
|
-
(state.lastHeartbeatAtMs + nudgeStaleHeartbeatThresholdMs) - Date.now(),
|
|
792
|
-
);
|
|
793
|
-
state.staleHeartbeatTimer = setTimeout(() => {
|
|
794
|
-
state.staleHeartbeatTimer = null;
|
|
795
|
-
const currentState = clientNudgeState.get(clientId);
|
|
796
|
-
if (
|
|
797
|
-
!currentState ||
|
|
798
|
-
currentState.nudgeActive ||
|
|
799
|
-
interactionStageBucket(currentState.interactionStage) === "idle"
|
|
800
|
-
) {
|
|
801
|
-
return;
|
|
802
|
-
}
|
|
803
|
-
if (!hasStaleHeartbeat(currentState)) {
|
|
804
|
-
scheduleClientStaleHeartbeatCheck(clientId);
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
startClientNudges(
|
|
808
|
-
clientId,
|
|
809
|
-
nudgeActiveIntervalMs,
|
|
810
|
-
"stale_heartbeat_fallback",
|
|
811
|
-
true,
|
|
812
|
-
);
|
|
813
|
-
}, delayMs);
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
function maybeActivateClientNudges(clientId, reason = "nudge_eval") {
|
|
817
|
-
const state = clientNudgeState.get(clientId);
|
|
818
|
-
if (!state || !isAppClient(clientId)) return;
|
|
819
|
-
state.cadenceBucket = interactionStageBucket(state.interactionStage);
|
|
820
|
-
if (state.cadenceBucket === "idle") {
|
|
821
|
-
clearClientNudgeTimer(state, "staleHeartbeatTimer");
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
if (state.nudgeActive) {
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
if (isVisibilityDegraded(state) || hasStaleHeartbeat(state)) {
|
|
828
|
-
startClientNudges(clientId, nudgeActiveIntervalMs, reason, true);
|
|
829
|
-
return;
|
|
830
|
-
}
|
|
831
|
-
scheduleClientStaleHeartbeatCheck(clientId);
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
function setClientInteractionStage(clientId, nextStage, options = {}) {
|
|
835
|
-
if (!isAppClient(clientId)) return;
|
|
836
|
-
const state = ensureClientNudgeState(clientId);
|
|
837
|
-
const reason = options.reason || "interaction_stage";
|
|
838
|
-
const deactivateImmediately = options.deactivateImmediately === true;
|
|
839
|
-
state.interactionStage = nextStage;
|
|
840
|
-
state.cadenceBucket = interactionStageBucket(nextStage);
|
|
841
|
-
if (state.cadenceBucket !== "active_stream") {
|
|
842
|
-
resetClientStallTracking(state);
|
|
843
|
-
}
|
|
844
|
-
if (state.cadenceBucket === "idle") {
|
|
845
|
-
clearClientNudgeTimer(state, "staleHeartbeatTimer");
|
|
846
|
-
if (deactivateImmediately) {
|
|
847
|
-
stopClientNudges(clientId, reason);
|
|
848
|
-
} else {
|
|
849
|
-
scheduleClientIdleDeactivation(clientId);
|
|
850
|
-
}
|
|
851
|
-
return;
|
|
852
|
-
}
|
|
853
|
-
clearClientNudgeTimer(state, "idleDeactivateTimer");
|
|
854
|
-
if (
|
|
855
|
-
state.cadenceBucket === "active_non_stream" &&
|
|
856
|
-
state.nudgeActive &&
|
|
857
|
-
state.nudgeIntervalMs !== nudgeActiveIntervalMs
|
|
858
|
-
) {
|
|
859
|
-
startClientNudges(clientId, nudgeActiveIntervalMs, reason, false);
|
|
860
|
-
}
|
|
861
|
-
maybeActivateClientNudges(clientId, reason);
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
function observeRelayStreamingActivity(clientId, atMs) {
|
|
865
|
-
if (!isAppClient(clientId)) return;
|
|
866
|
-
const state = ensureClientNudgeState(clientId);
|
|
867
|
-
state.lastRelayStreamingActivityAtMs = atMs;
|
|
868
|
-
resetClientStallTracking(state);
|
|
869
|
-
if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
|
|
870
|
-
startClientNudges(clientId, nudgeActiveIntervalMs, "relay_stream_progress");
|
|
871
|
-
return;
|
|
872
|
-
}
|
|
873
|
-
maybeActivateClientNudges(clientId, "relay_stream_activity");
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
function updateClientNudgeHeartbeat(clientId, ping) {
|
|
877
|
-
const state = ensureClientNudgeState(clientId);
|
|
878
|
-
const previousHeartbeatAtMs = Number.isFinite(state.lastHeartbeatAtMs)
|
|
879
|
-
? state.lastHeartbeatAtMs
|
|
880
|
-
: null;
|
|
881
|
-
const previousStreamChars = Number.isFinite(state.streamChars)
|
|
882
|
-
? state.streamChars
|
|
883
|
-
: null;
|
|
884
|
-
const nextStreamChars = Number.isFinite(ping.streamChars) ? ping.streamChars : null;
|
|
885
|
-
const streamAdvanced =
|
|
886
|
-
nextStreamChars !== null &&
|
|
887
|
-
(previousStreamChars === null || nextStreamChars > previousStreamChars);
|
|
888
|
-
const relayStreamAdvanced =
|
|
889
|
-
previousHeartbeatAtMs !== null &&
|
|
890
|
-
Number.isFinite(state.lastRelayStreamingActivityAtMs) &&
|
|
891
|
-
state.lastRelayStreamingActivityAtMs > previousHeartbeatAtMs;
|
|
892
|
-
state.streamChars = nextStreamChars;
|
|
893
|
-
if (ping.visibilityState) {
|
|
894
|
-
state.visibilityState = ping.visibilityState;
|
|
895
|
-
}
|
|
896
|
-
state.lastHeartbeatAtMs = Date.now();
|
|
897
|
-
state.cadenceBucket = interactionStageBucket(state.interactionStage);
|
|
898
|
-
if (state.visibilityState === "visible") {
|
|
899
|
-
stopClientNudges(clientId, "heartbeat_visible");
|
|
900
|
-
}
|
|
901
|
-
if (state.cadenceBucket === "active_stream") {
|
|
902
|
-
if (streamAdvanced || relayStreamAdvanced) {
|
|
903
|
-
resetClientStallTracking(state);
|
|
904
|
-
if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
|
|
905
|
-
startClientNudges(
|
|
906
|
-
clientId,
|
|
907
|
-
nudgeActiveIntervalMs,
|
|
908
|
-
streamAdvanced
|
|
909
|
-
? "heartbeat_stream_progress"
|
|
910
|
-
: "relay_stream_progress",
|
|
911
|
-
false,
|
|
912
|
-
);
|
|
913
|
-
}
|
|
914
|
-
} else if (state.nudgeActive) {
|
|
915
|
-
state.stalledHeartbeatCount += 1;
|
|
916
|
-
if (
|
|
917
|
-
state.stalledHeartbeatCount >= 3 &&
|
|
918
|
-
state.nudgeIntervalMs !== nudgeSlowIntervalMs
|
|
919
|
-
) {
|
|
920
|
-
startClientNudges(
|
|
921
|
-
clientId,
|
|
922
|
-
nudgeSlowIntervalMs,
|
|
923
|
-
"stream_stalled_decelerated",
|
|
924
|
-
false,
|
|
925
|
-
);
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
} else {
|
|
929
|
-
resetClientStallTracking(state);
|
|
930
|
-
if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
|
|
931
|
-
startClientNudges(clientId, nudgeActiveIntervalMs, "non_stream_fast", false);
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
maybeActivateClientNudges(clientId, "heartbeat_update");
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
function updateClientVisibilityState(clientId, visibilityState) {
|
|
938
|
-
const state = ensureClientNudgeState(clientId);
|
|
939
|
-
state.visibilityState = visibilityState;
|
|
940
|
-
if (visibilityState === "visible") {
|
|
941
|
-
stopClientNudges(clientId, "visibility_visible");
|
|
942
|
-
return;
|
|
943
|
-
}
|
|
944
|
-
maybeActivateClientNudges(clientId, "visibility_hidden");
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
function applyBroadcastInteractionStage(messageType, parsed, relayStreamingActivityAtMs) {
|
|
948
|
-
for (const [clientId] of clients) {
|
|
949
|
-
if (!isAppClient(clientId)) {
|
|
950
|
-
continue;
|
|
951
|
-
}
|
|
952
|
-
if (relayStreamingActivityAtMs !== null) {
|
|
953
|
-
setClientInteractionStage(clientId, "streaming", {
|
|
954
|
-
reason: "relay_streaming",
|
|
955
|
-
});
|
|
956
|
-
observeRelayStreamingActivity(clientId, relayStreamingActivityAtMs);
|
|
957
|
-
continue;
|
|
958
|
-
}
|
|
959
|
-
if (isListenCommittedBroadcastType(messageType)) {
|
|
960
|
-
setClientInteractionStage(clientId, "voice_handoff", {
|
|
961
|
-
reason: "listen_committed",
|
|
962
|
-
});
|
|
963
|
-
continue;
|
|
964
|
-
}
|
|
965
|
-
if (isListenEndedBroadcastType(messageType)) {
|
|
966
|
-
setClientInteractionStage(clientId, "idle", {
|
|
967
|
-
reason: "listen_ended",
|
|
968
|
-
deactivateImmediately: true,
|
|
969
|
-
});
|
|
970
|
-
continue;
|
|
971
|
-
}
|
|
972
|
-
if (isActivityBroadcastType(messageType)) {
|
|
973
|
-
const activityState = parseOptionalTrimmedString(parsed && parsed.state);
|
|
974
|
-
const normalizedActivity = activityState ? activityState.toLowerCase() : null;
|
|
975
|
-
const currentStage = ensureClientNudgeState(clientId).interactionStage;
|
|
976
|
-
if (normalizedActivity === "thinking") {
|
|
977
|
-
setClientInteractionStage(clientId, "thinking", {
|
|
978
|
-
reason: "activity_thinking",
|
|
979
|
-
});
|
|
980
|
-
} else if (normalizedActivity === "idle") {
|
|
981
|
-
if (currentStage === "streaming" || currentStage === "post_turn_drain") {
|
|
982
|
-
setClientInteractionStage(clientId, "post_turn_drain", {
|
|
983
|
-
reason: "activity_idle_stream_drain",
|
|
984
|
-
});
|
|
985
|
-
} else {
|
|
986
|
-
setClientInteractionStage(clientId, "idle", {
|
|
987
|
-
reason: "activity_idle",
|
|
988
|
-
});
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
continue;
|
|
992
|
-
}
|
|
993
|
-
if (isPagesBroadcastType(messageType)) {
|
|
994
|
-
const currentState = ensureClientNudgeState(clientId);
|
|
995
|
-
if (interactionStageBucket(currentState.interactionStage) !== "idle") {
|
|
996
|
-
setClientInteractionStage(clientId, "post_turn_drain", {
|
|
997
|
-
reason: "pages_snapshot",
|
|
998
|
-
});
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
694
|
function updateProtocolSessionKey(clientId, value) {
|
|
1005
695
|
const sessionKey = parseOptionalTrimmedString(value);
|
|
1006
696
|
if (!sessionKey) return;
|
|
@@ -1061,11 +751,35 @@ function createDownstreamServer(opts) {
|
|
|
1061
751
|
}, resumeHandshakeTimeoutMs);
|
|
1062
752
|
}
|
|
1063
753
|
|
|
1064
|
-
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) {
|
|
1065
764
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
1066
765
|
return;
|
|
1067
766
|
}
|
|
1068
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
|
+
}
|
|
1069
783
|
}
|
|
1070
784
|
|
|
1071
785
|
function forwardReadinessProbeAck(clientId, ack) {
|
|
@@ -1112,36 +826,177 @@ function createDownstreamServer(opts) {
|
|
|
1112
826
|
sendMessageToClient(pending.requesterClientId, requesterWs, message);
|
|
1113
827
|
}
|
|
1114
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
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
1115
983
|
function getServerResumeState() {
|
|
1116
984
|
if (!getCurrentResumeState) {
|
|
1117
985
|
return { pagesRevision: null, statusRevision: null };
|
|
1118
986
|
}
|
|
1119
987
|
const state = getCurrentResumeState() || {};
|
|
1120
988
|
return {
|
|
1121
|
-
pagesRevision:
|
|
1122
|
-
|
|
989
|
+
pagesRevision: RelayWorkerProtocol.parseNonNegativeRevision(
|
|
990
|
+
state.pagesRevision,
|
|
991
|
+
),
|
|
992
|
+
statusRevision: RelayWorkerProtocol.parseNonNegativeRevision(
|
|
993
|
+
state.statusRevision,
|
|
994
|
+
),
|
|
1123
995
|
};
|
|
1124
996
|
}
|
|
1125
997
|
|
|
1126
998
|
function formatResumeAck(payload) {
|
|
1127
|
-
return
|
|
1128
|
-
type: APP_PROTOCOL.resumeAck,
|
|
1129
|
-
reason: payload.reason || null,
|
|
1130
|
-
sentPages: !!payload.sentPages,
|
|
1131
|
-
sentStatus: !!payload.sentStatus,
|
|
1132
|
-
sentApprovals:
|
|
1133
|
-
Number.isFinite(payload.sentApprovals) && payload.sentApprovals > 0
|
|
1134
|
-
? Math.floor(payload.sentApprovals)
|
|
1135
|
-
: 0,
|
|
1136
|
-
pagesRevision:
|
|
1137
|
-
payload.pagesRevision === null || payload.pagesRevision === undefined
|
|
1138
|
-
? null
|
|
1139
|
-
: payload.pagesRevision,
|
|
1140
|
-
statusRevision:
|
|
1141
|
-
payload.statusRevision === null || payload.statusRevision === undefined
|
|
1142
|
-
? null
|
|
1143
|
-
: payload.statusRevision,
|
|
1144
|
-
});
|
|
999
|
+
return RelayWorkerProtocol.formatResumeAck(payload);
|
|
1145
1000
|
}
|
|
1146
1001
|
|
|
1147
1002
|
function isSnapshotSynced(sync) {
|
|
@@ -1195,10 +1050,10 @@ function createDownstreamServer(opts) {
|
|
|
1195
1050
|
const status = getCurrentStatus();
|
|
1196
1051
|
const debugConfig = getCurrentDebugConfig ? getCurrentDebugConfig() : null;
|
|
1197
1052
|
const state = getServerResumeState();
|
|
1198
|
-
const clientPagesRevision =
|
|
1053
|
+
const clientPagesRevision = RelayWorkerProtocol.parseNonNegativeRevision(
|
|
1199
1054
|
resumeRequest && resumeRequest.pagesRevision,
|
|
1200
1055
|
);
|
|
1201
|
-
const clientStatusRevision =
|
|
1056
|
+
const clientStatusRevision = RelayWorkerProtocol.parseNonNegativeRevision(
|
|
1202
1057
|
resumeRequest && resumeRequest.statusRevision,
|
|
1203
1058
|
);
|
|
1204
1059
|
const clientHasPagesState = parseBool(
|
|
@@ -1283,12 +1138,6 @@ function createDownstreamServer(opts) {
|
|
|
1283
1138
|
if (opts.httpServer) {
|
|
1284
1139
|
wss = new WebSocketServer({ noServer: true });
|
|
1285
1140
|
opts.httpServer.on("upgrade", (req, socket, head) => {
|
|
1286
|
-
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1287
|
-
if (url.searchParams.get("token") !== token) {
|
|
1288
|
-
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
1289
|
-
socket.destroy();
|
|
1290
|
-
return;
|
|
1291
|
-
}
|
|
1292
1141
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1293
1142
|
wss.emit("connection", ws, req);
|
|
1294
1143
|
});
|
|
@@ -1297,14 +1146,15 @@ function createDownstreamServer(opts) {
|
|
|
1297
1146
|
wss = new WebSocketServer({
|
|
1298
1147
|
host: opts.host,
|
|
1299
1148
|
port: opts.port,
|
|
1300
|
-
verifyClient: ({ req }) => {
|
|
1301
|
-
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1302
|
-
return url.searchParams.get("token") === token;
|
|
1303
|
-
},
|
|
1304
1149
|
});
|
|
1305
1150
|
}
|
|
1306
1151
|
|
|
1307
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
|
+
}
|
|
1308
1158
|
const clientId = `client-${nextClientId++}`;
|
|
1309
1159
|
clients.set(clientId, ws);
|
|
1310
1160
|
const connectedAtMs = Date.now();
|
|
@@ -1321,7 +1171,7 @@ function createDownstreamServer(opts) {
|
|
|
1321
1171
|
connectedEventEmitted: false,
|
|
1322
1172
|
};
|
|
1323
1173
|
ensureProtocolState(clientId);
|
|
1324
|
-
|
|
1174
|
+
nudgeController.addClient(clientId);
|
|
1325
1175
|
const currentPages = getCurrentPages();
|
|
1326
1176
|
const currentStatus = getCurrentStatus();
|
|
1327
1177
|
syncState.set(clientId, {
|
|
@@ -1380,7 +1230,7 @@ function createDownstreamServer(opts) {
|
|
|
1380
1230
|
|
|
1381
1231
|
// Intercept application-level ping — respond with pong, skip handler
|
|
1382
1232
|
if (enrichedPing) {
|
|
1383
|
-
|
|
1233
|
+
nudgeController.updateHeartbeat(clientId, enrichedPing);
|
|
1384
1234
|
ws.send(JSON.stringify({ type: "pong", ts: parsed.ts }));
|
|
1385
1235
|
return;
|
|
1386
1236
|
}
|
|
@@ -1486,6 +1336,14 @@ function createDownstreamServer(opts) {
|
|
|
1486
1336
|
return;
|
|
1487
1337
|
}
|
|
1488
1338
|
|
|
1339
|
+
const automationStateSnapshot = parseAutomationStateSnapshot(parsed);
|
|
1340
|
+
if (automationStateSnapshot) {
|
|
1341
|
+
if (classifyClientKind(state) === "app") {
|
|
1342
|
+
forwardAutomationStateSnapshot(clientId, automationStateSnapshot);
|
|
1343
|
+
}
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1489
1347
|
if (parsed && parsed.type === APP_PROTOCOL.resume) {
|
|
1490
1348
|
const sync = syncState.get(clientId);
|
|
1491
1349
|
if (sync) sync.resumeReceived = true;
|
|
@@ -1496,15 +1354,25 @@ function createDownstreamServer(opts) {
|
|
|
1496
1354
|
return;
|
|
1497
1355
|
}
|
|
1498
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
|
+
|
|
1499
1367
|
const visibilityControl = parseVisibilityControl(parsed);
|
|
1500
1368
|
const drainCompleteControl = parseDrainCompleteControl(parsed);
|
|
1501
1369
|
const transportControl = visibilityControl || drainCompleteControl;
|
|
1502
1370
|
if (transportControl) {
|
|
1503
1371
|
const protocol = protocolState.get(clientId);
|
|
1504
1372
|
if (transportControl.type === "visibility") {
|
|
1505
|
-
|
|
1373
|
+
nudgeController.updateVisibilityState(clientId, transportControl.state);
|
|
1506
1374
|
} else {
|
|
1507
|
-
|
|
1375
|
+
nudgeController.setInteractionStage(clientId, "idle", {
|
|
1508
1376
|
reason: "drain_complete",
|
|
1509
1377
|
deactivateImmediately: true,
|
|
1510
1378
|
});
|
|
@@ -1537,6 +1405,229 @@ function createDownstreamServer(opts) {
|
|
|
1537
1405
|
return;
|
|
1538
1406
|
}
|
|
1539
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
|
+
|
|
1540
1631
|
const result = handler.handleMessage(clientId, raw);
|
|
1541
1632
|
processResult(clientId, result);
|
|
1542
1633
|
});
|
|
@@ -1551,8 +1642,8 @@ function createDownstreamServer(opts) {
|
|
|
1551
1642
|
protocolState.delete(clientId);
|
|
1552
1643
|
clientReadinessSnapshotState.delete(clientId);
|
|
1553
1644
|
clearPendingReadinessProbesForClient(clientId);
|
|
1554
|
-
|
|
1555
|
-
|
|
1645
|
+
clearPendingAutomationStateRequestsForClient(clientId);
|
|
1646
|
+
nudgeController.deleteClient(clientId);
|
|
1556
1647
|
handler.removeClient(clientId);
|
|
1557
1648
|
clients.delete(clientId);
|
|
1558
1649
|
const lifetimeMs = Date.now() - connectedAtMs;
|
|
@@ -1598,15 +1689,36 @@ function createDownstreamServer(opts) {
|
|
|
1598
1689
|
* @param {string} senderId - Client ID that sent the original message
|
|
1599
1690
|
* @param {object|null|Promise} result - Handler return value
|
|
1600
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
|
+
|
|
1601
1703
|
function processResult(senderId, result) {
|
|
1602
1704
|
if (result === null || result === undefined) {
|
|
1603
1705
|
return;
|
|
1604
1706
|
}
|
|
1605
1707
|
|
|
1606
1708
|
if (typeof result.then === "function") {
|
|
1709
|
+
pendingHandlerPromiseCount += 1;
|
|
1710
|
+
if (relayHealth && typeof relayHealth.emitQueueDepth === "function") {
|
|
1711
|
+
relayHealth.emitQueueDepth({
|
|
1712
|
+
pendingHandlerPromises: pendingHandlerPromiseCount,
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1607
1715
|
result.then(
|
|
1608
|
-
(resolved) =>
|
|
1716
|
+
(resolved) => {
|
|
1717
|
+
pendingHandlerPromiseCount = Math.max(0, pendingHandlerPromiseCount - 1);
|
|
1718
|
+
processResult(senderId, resolved);
|
|
1719
|
+
},
|
|
1609
1720
|
(err) => {
|
|
1721
|
+
pendingHandlerPromiseCount = Math.max(0, pendingHandlerPromiseCount - 1);
|
|
1610
1722
|
logger.error(
|
|
1611
1723
|
`[downstream] Error processing message from ${senderId}:`,
|
|
1612
1724
|
err,
|
|
@@ -1617,10 +1729,11 @@ function createDownstreamServer(opts) {
|
|
|
1617
1729
|
}
|
|
1618
1730
|
|
|
1619
1731
|
if (result.unicast) {
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1732
|
+
sendUnicastResult(senderId, result.unicast);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
if (result.followup && typeof result.followup.then === "function") {
|
|
1736
|
+
processResult(senderId, result.followup);
|
|
1624
1737
|
}
|
|
1625
1738
|
|
|
1626
1739
|
if (result.readinessProbe) {
|
|
@@ -1671,6 +1784,58 @@ function createDownstreamServer(opts) {
|
|
|
1671
1784
|
}
|
|
1672
1785
|
}
|
|
1673
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);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1674
1839
|
if (result.broadcast) {
|
|
1675
1840
|
if (Array.isArray(result.broadcast)) {
|
|
1676
1841
|
for (const msg of result.broadcast) broadcast(msg);
|
|
@@ -1731,11 +1896,7 @@ function createDownstreamServer(opts) {
|
|
|
1731
1896
|
: null;
|
|
1732
1897
|
for (const [clientId, ws] of clients) {
|
|
1733
1898
|
if (ws.readyState === WebSocket.OPEN) {
|
|
1734
|
-
sendMessageToClient(clientId, ws, message);
|
|
1735
|
-
if (relayStreamingActivityAtMs !== null) {
|
|
1736
|
-
ensureClientNudgeState(clientId).lastRelayStreamingActivityAtMs =
|
|
1737
|
-
relayStreamingActivityAtMs;
|
|
1738
|
-
}
|
|
1899
|
+
sendMessageToClient(clientId, ws, message, messageType);
|
|
1739
1900
|
if (
|
|
1740
1901
|
messageType === "pages" ||
|
|
1741
1902
|
messageType === "status" ||
|
|
@@ -1746,7 +1907,11 @@ function createDownstreamServer(opts) {
|
|
|
1746
1907
|
}
|
|
1747
1908
|
}
|
|
1748
1909
|
}
|
|
1749
|
-
applyBroadcastInteractionStage(
|
|
1910
|
+
nudgeController.applyBroadcastInteractionStage(
|
|
1911
|
+
messageType,
|
|
1912
|
+
parsed,
|
|
1913
|
+
relayStreamingActivityAtMs,
|
|
1914
|
+
);
|
|
1750
1915
|
}
|
|
1751
1916
|
|
|
1752
1917
|
/**
|
|
@@ -1772,7 +1937,7 @@ function createDownstreamServer(opts) {
|
|
|
1772
1937
|
if (ws.readyState !== WebSocket.OPEN) {
|
|
1773
1938
|
continue;
|
|
1774
1939
|
}
|
|
1775
|
-
sendMessageToClient(clientId, ws, message);
|
|
1940
|
+
sendMessageToClient(clientId, ws, message, messageType);
|
|
1776
1941
|
if (
|
|
1777
1942
|
messageType === "pages" ||
|
|
1778
1943
|
messageType === "status" ||
|
|
@@ -1807,8 +1972,8 @@ function createDownstreamServer(opts) {
|
|
|
1807
1972
|
return Array.from(clients.keys());
|
|
1808
1973
|
}
|
|
1809
1974
|
|
|
1810
|
-
function getConnectedAppCount(excludeClientId = null) {
|
|
1811
|
-
return countConnectedAppClients(excludeClientId);
|
|
1975
|
+
function getConnectedAppCount(excludeClientId = null, sessionKey = null) {
|
|
1976
|
+
return countConnectedAppClients(excludeClientId, sessionKey);
|
|
1812
1977
|
}
|
|
1813
1978
|
|
|
1814
1979
|
function getReadinessSnapshot() {
|
|
@@ -1853,12 +2018,13 @@ function createDownstreamServer(opts) {
|
|
|
1853
2018
|
if (sync.timer) clearTimeout(sync.timer);
|
|
1854
2019
|
}
|
|
1855
2020
|
syncState.clear();
|
|
1856
|
-
|
|
1857
|
-
clearClientNudgeTimers(clientId);
|
|
1858
|
-
}
|
|
1859
|
-
clientNudgeState.clear();
|
|
2021
|
+
nudgeController.clear();
|
|
1860
2022
|
clientReadinessSnapshotState.clear();
|
|
1861
2023
|
pendingReadinessProbeRequests.clear();
|
|
2024
|
+
for (const [, pending] of pendingAutomationStateRequests) {
|
|
2025
|
+
if (pending && pending.expiryTimer) clearTimeout(pending.expiryTimer);
|
|
2026
|
+
}
|
|
2027
|
+
pendingAutomationStateRequests.clear();
|
|
1862
2028
|
for (const [, ws] of clients) {
|
|
1863
2029
|
ws.close();
|
|
1864
2030
|
}
|
|
@@ -1875,7 +2041,7 @@ function createDownstreamServer(opts) {
|
|
|
1875
2041
|
getReadinessSnapshot,
|
|
1876
2042
|
closeConnectedAppClients,
|
|
1877
2043
|
getClientNudgeState(clientId) {
|
|
1878
|
-
return
|
|
2044
|
+
return nudgeController.getClientState(clientId);
|
|
1879
2045
|
},
|
|
1880
2046
|
close,
|
|
1881
2047
|
get httpServer() {
|