dsh-zen-remote 2.3.11 → 2.4.1
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/CHANGELOG.md +45 -78
- package/README.md +94 -84
- package/README.zh-CN.md +90 -81
- package/bin/zen-remote.mjs +5 -0
- package/docs/interface.md +6 -6
- package/docs/remote-access.en.md +6 -4
- package/docs/remote-access.md +6 -4
- package/dsh-push.mjs +96 -10
- package/lib/adapters/dsh-better-sidebar/index.js +7 -3
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/admin-routes.js +10 -2
- package/lib/admin-routes.js.map +1 -1
- package/lib/cli/args.js +251 -0
- package/lib/cli/args.js.map +1 -0
- package/lib/cli/bootstrap.js +449 -0
- package/lib/cli/bootstrap.js.map +1 -0
- package/lib/cli/command-setup.js +484 -0
- package/lib/cli/command-setup.js.map +1 -0
- package/lib/cli/commands-client.js +359 -0
- package/lib/cli/commands-client.js.map +1 -0
- package/lib/cli/commands-host.js +1108 -0
- package/lib/cli/commands-host.js.map +1 -0
- package/lib/cli/commands-status.js +279 -0
- package/lib/cli/commands-status.js.map +1 -0
- package/lib/cli/config-writer.js +334 -0
- package/lib/cli/config-writer.js.map +1 -0
- package/lib/cli/context.js +10 -0
- package/lib/cli/context.js.map +1 -0
- package/lib/cli/i18n.js +620 -0
- package/lib/cli/i18n.js.map +1 -0
- package/lib/cli/ipc-client.js +160 -0
- package/lib/cli/ipc-client.js.map +1 -0
- package/lib/cli/ipc-server.js +464 -0
- package/lib/cli/ipc-server.js.map +1 -0
- package/lib/cli/main.js +245 -0
- package/lib/cli/main.js.map +1 -0
- package/lib/cli/qr-terminal.js +48 -0
- package/lib/cli/qr-terminal.js.map +1 -0
- package/lib/client-data/qr.js +286 -0
- package/lib/client-data/qr.js.map +1 -0
- package/lib/client-routes.js +308 -25
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1013 -229
- package/lib/config.js +50 -12
- package/lib/config.js.map +1 -1
- package/lib/debug-report.js +28 -1
- package/lib/debug-report.js.map +1 -1
- package/lib/fetch-route-intercept.js +334 -26
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +608 -375
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +1833 -280
- package/lib/intercept.js.map +1 -1
- package/lib/lan-gate-server.cjs +36 -10
- package/lib/merge-streams.js +103 -11
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-route-intercept.js +93 -63
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/relay-access.js +8 -1
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +133 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-pool.js +275 -0
- package/lib/relay-pool.js.map +1 -0
- package/lib/relay-server.js +269 -17
- package/lib/relay-server.js.map +1 -1
- package/lib/restart-fields.js +6 -4
- package/lib/restart-fields.js.map +1 -1
- package/lib/share-ops.js +68 -0
- package/lib/share-ops.js.map +1 -1
- package/lib/share-store.js +65 -27
- package/lib/share-store.js.map +1 -1
- package/lib/state-store.js +400 -0
- package/lib/state-store.js.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/cli/args.d.ts +78 -0
- package/lib/types/cli/args.d.ts.map +1 -0
- package/lib/types/cli/bootstrap.d.ts +170 -0
- package/lib/types/cli/bootstrap.d.ts.map +1 -0
- package/lib/types/cli/command-setup.d.ts +72 -0
- package/lib/types/cli/command-setup.d.ts.map +1 -0
- package/lib/types/cli/commands-client.d.ts +36 -0
- package/lib/types/cli/commands-client.d.ts.map +1 -0
- package/lib/types/cli/commands-host.d.ts +82 -0
- package/lib/types/cli/commands-host.d.ts.map +1 -0
- package/lib/types/cli/commands-status.d.ts +46 -0
- package/lib/types/cli/commands-status.d.ts.map +1 -0
- package/lib/types/cli/config-writer.d.ts +92 -0
- package/lib/types/cli/config-writer.d.ts.map +1 -0
- package/lib/types/cli/context.d.ts +39 -0
- package/lib/types/cli/context.d.ts.map +1 -0
- package/lib/types/cli/i18n.d.ts +32 -0
- package/lib/types/cli/i18n.d.ts.map +1 -0
- package/lib/types/cli/ipc-client.d.ts +83 -0
- package/lib/types/cli/ipc-client.d.ts.map +1 -0
- package/lib/types/cli/ipc-server.d.ts +113 -0
- package/lib/types/cli/ipc-server.d.ts.map +1 -0
- package/lib/types/cli/main.d.ts +57 -0
- package/lib/types/cli/main.d.ts.map +1 -0
- package/lib/types/cli/qr-terminal.d.ts +17 -0
- package/lib/types/cli/qr-terminal.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +4 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -1
- package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +47 -3
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/settings/settings-css.d.ts +1 -1
- package/lib/types/client/settings/settings-css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
- package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
- package/lib/types/client-data/model-group-side.d.ts +23 -2
- package/lib/types/client-data/model-group-side.d.ts.map +1 -1
- package/lib/types/client-data/remote-status.d.ts +47 -1
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +85 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-data/wizard.d.ts +56 -1
- package/lib/types/client-data/wizard.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +25 -1
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +40 -14
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/debug-report.d.ts.map +1 -1
- package/lib/types/fetch-route-intercept.d.ts +66 -8
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +9 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +72 -10
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +79 -1
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +25 -9
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/relay-access.d.ts +3 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-client.d.ts +23 -2
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-pool.d.ts +148 -0
- package/lib/types/relay-pool.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +30 -3
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/restart-fields.d.ts +5 -5
- package/lib/types/restart-fields.d.ts.map +1 -1
- package/lib/types/share-ops.d.ts +47 -1
- package/lib/types/share-ops.d.ts.map +1 -1
- package/lib/types/share-store.d.ts.map +1 -1
- package/lib/types/state-store.d.ts +179 -0
- package/lib/types/state-store.d.ts.map +1 -0
- package/lib/types/upload-limits.d.ts +15 -0
- package/lib/types/upload-limits.d.ts.map +1 -1
- package/lib/types/virtual-id.d.ts +21 -0
- package/lib/types/virtual-id.d.ts.map +1 -1
- package/lib/types/wizard/probe.d.ts.map +1 -1
- package/lib/types/wizard/proxy-snippets.d.ts +3 -0
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/types/wizard/routes.d.ts +1 -0
- package/lib/types/wizard/routes.d.ts.map +1 -1
- package/lib/types/wizard/status.d.ts +9 -0
- package/lib/types/wizard/status.d.ts.map +1 -1
- package/lib/types/wizard/verify.d.ts +3 -0
- package/lib/types/wizard/verify.d.ts.map +1 -1
- package/lib/upload-limits.js +15 -0
- package/lib/upload-limits.js.map +1 -1
- package/lib/virtual-id.js +60 -0
- package/lib/virtual-id.js.map +1 -1
- package/lib/wizard/probe.js +46 -1
- package/lib/wizard/probe.js.map +1 -1
- package/lib/wizard/proxy-snippets.js +14 -1
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/lib/wizard/routes.js +23 -4
- package/lib/wizard/routes.js.map +1 -1
- package/lib/wizard/status.js +21 -0
- package/lib/wizard/status.js.map +1 -1
- package/lib/wizard/verify.js +31 -8
- package/lib/wizard/verify.js.map +1 -1
- package/package.json +8 -4
- package/pwa/icons/badge-96.png +0 -0
- package/pwa/icons/badge.svg +1 -0
- package/pwa/manifest.json +2 -1
- package/pwa/sw.js +3 -1
- package/scripts/check-debug-report.mjs +41 -1
- package/scripts/check-settings-form.mjs +14 -3
- package/scripts/check-settings-view.mjs +247 -0
- package/scripts/check-wizard-ui.mjs +37 -0
- package/src/adapters/dsh-better-sidebar/index.ts +7 -3
- package/src/admin-routes.ts +10 -2
- package/src/cli/args.ts +289 -0
- package/src/cli/bootstrap.ts +569 -0
- package/src/cli/command-setup.ts +540 -0
- package/src/cli/commands-client.ts +369 -0
- package/src/cli/commands-host.ts +1127 -0
- package/src/cli/commands-status.ts +296 -0
- package/src/cli/config-writer.ts +354 -0
- package/src/cli/context.ts +38 -0
- package/src/cli/i18n.ts +645 -0
- package/src/cli/ipc-client.ts +216 -0
- package/src/cli/ipc-server.ts +531 -0
- package/src/cli/main.ts +285 -0
- package/src/cli/qr-terminal.ts +49 -0
- package/src/client/RemoteStatusIcon.tsx +4 -1
- package/src/client/current-session-report.ts +28 -4
- package/src/client/effects/model-group-side.ts +19 -5
- package/src/client/effects/remote-session.ts +26 -6
- package/src/client/locales.ts +96 -6
- package/src/client/settings/SettingsSection.tsx +287 -92
- package/src/client/settings/settings-css.ts +18 -0
- package/src/client/styles/remote-session.css.ts +15 -1
- package/src/client/wizard/GatewayWizard.tsx +470 -109
- package/src/client/wizard/cards/ProxyCard.tsx +2 -1
- package/src/client/wizard/wizard-css.ts +23 -0
- package/src/client-data/model-group-side.ts +37 -10
- package/src/client-data/remote-status.ts +112 -17
- package/src/client-data/settings-form.ts +164 -8
- package/src/client-data/wizard.ts +112 -2
- package/src/client-routes.ts +334 -26
- package/src/config.ts +62 -16
- package/src/debug-report.ts +27 -1
- package/src/fetch-route-intercept.ts +381 -32
- package/src/index.ts +608 -380
- package/src/intercept.ts +1866 -286
- package/src/merge-streams.ts +155 -10
- package/src/plugin-route-intercept.ts +120 -71
- package/src/relay-access.ts +14 -3
- package/src/relay-client.ts +163 -5
- package/src/relay-pool.ts +387 -0
- package/src/relay-server.ts +312 -16
- package/src/restart-fields.ts +6 -4
- package/src/share-ops.ts +82 -1
- package/src/share-store.ts +59 -26
- package/src/state-store.ts +428 -0
- package/src/upload-limits.ts +16 -0
- package/src/virtual-id.ts +57 -0
- package/src/wizard/probe.ts +54 -2
- package/src/wizard/proxy-snippets.ts +17 -2
- package/src/wizard/routes.ts +24 -5
- package/src/wizard/status.ts +28 -0
- package/src/wizard/verify.ts +33 -11
- package/AGENTS.md +0 -185
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
- package/docs/adr/0002-relay-emulates-websocket.md +0 -8
package/lib/intercept.js
CHANGED
|
@@ -82,7 +82,7 @@ import { RelayError, defaultClock } from './relay-client.js';
|
|
|
82
82
|
import { checkGatewayShape } from './intercept-shape.js';
|
|
83
83
|
import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js';
|
|
84
84
|
import { INVOKE_BINARY_RESULT_METHODS, extractResultAttachments, restoreInvokeBinary } from './invoke-binary.js';
|
|
85
|
-
import { createControlMerger, createWorkspaceMerger, mergeModelCatalogs, mergeSessionList, sessionSummaryAddedFrames, virtualSessionIdsInWorkspaceFrames, virtualizeModelSelectionValue, } from './merge-streams.js';
|
|
85
|
+
import { composeMergedWorkspaceFrames, createControlMerger, createWorkspaceMerger, localWorkspaceLists, mergeModelCatalogs, mergeSessionList, sessionSummaryAddedFrames, virtualSessionIdsInWorkspaceFrames, virtualizeModelSelectionValue, } from './merge-streams.js';
|
|
86
86
|
/**
|
|
87
87
|
* The client-side half of the server's `RELAY_METHODS` registry: which
|
|
88
88
|
* argument field locates the session for each method. METHODS AND FIELDS
|
|
@@ -171,6 +171,11 @@ export const CLIENT_METHOD_FIELDS = {
|
|
|
171
171
|
'terminal/rename': ['agentId'],
|
|
172
172
|
'terminal/close': ['agentId'],
|
|
173
173
|
'terminal/follow': ['agentId'],
|
|
174
|
+
// TA-1: the 0.2.2 duplex stream (follow+write+resize merged), located by
|
|
175
|
+
// the same agentId. TA-2 wired the interceptor half: the host's uplink
|
|
176
|
+
// inbox and its window travel to the relay's openStream options (see
|
|
177
|
+
// REMOTE_UPLINK_STREAM_METHODS) instead of being half-closed.
|
|
178
|
+
'terminal/attach': ['agentId'],
|
|
174
179
|
// the retain/list half locates by a plain top-level json sessionId
|
|
175
180
|
'terminal/list': ['sessionId'],
|
|
176
181
|
'terminal/retain': ['sessionId'],
|
|
@@ -299,11 +304,50 @@ export const REMOTE_READ_METHODS = new Set([
|
|
|
299
304
|
export function isRemoteWrite(endpoint) {
|
|
300
305
|
return !REMOTE_READ_METHODS.has(endpoint);
|
|
301
306
|
}
|
|
302
|
-
/**
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
*
|
|
306
|
-
|
|
307
|
+
/**
|
|
308
|
+
* Stream endpoints whose caller-supplied `uplink` iterable and
|
|
309
|
+
* `uplinkWindow` travel to the relay instead of being half-closed locally
|
|
310
|
+
* with `releaseUplink`. DSH 0.2.2+ replaced `terminal/follow|write|resize`
|
|
311
|
+
* with the duplex `terminal/attach` stream (`TerminalInput` write/resize
|
|
312
|
+
* commands ride the uplink; `ack` frames ride the downlink). The relay
|
|
313
|
+
* client's pump (relay-client.ts) is the other half: it forwards the host's
|
|
314
|
+
* frames through the server's `/v1/stream-uplink` channel and releases the
|
|
315
|
+
* host inbox itself when the stream ends — the interceptor only hands the
|
|
316
|
+
* iterable over, it never half-closes a member of this set.
|
|
317
|
+
*/
|
|
318
|
+
export const REMOTE_UPLINK_STREAM_METHODS = new Set(['terminal/attach']);
|
|
319
|
+
const INTERCEPT_COPY = {
|
|
320
|
+
zh: {
|
|
321
|
+
writeOffline: '服务端离线,远程会话暂时只读',
|
|
322
|
+
closed: '远程已关闭',
|
|
323
|
+
notConnected: '尚未连接到服务端',
|
|
324
|
+
mismatch: '此远程会话属于其他主服务端',
|
|
325
|
+
unsupported: '此功能暂不支持远程会话',
|
|
326
|
+
referenceNotOnServer: '引用的会话不在服务端上,无法转发',
|
|
327
|
+
revoked: '配对令牌已被吊销,请在设置页重新配对',
|
|
328
|
+
unpaired: '尚未与服务端配对,请在设置页完成配对',
|
|
329
|
+
modelGroupRemote: '该模型分组属于远程服务端,无法在本机会话中使用',
|
|
330
|
+
modelGroupPickServer: (displayName) => `请选择「${displayName} · …」分组里的模型`,
|
|
331
|
+
modelGroupMismatch: '此模型分组属于其他主服务端',
|
|
332
|
+
},
|
|
333
|
+
en: {
|
|
334
|
+
writeOffline: 'Server offline — remote session is temporarily read-only',
|
|
335
|
+
closed: 'Remote closed',
|
|
336
|
+
notConnected: 'Not connected to the server yet',
|
|
337
|
+
mismatch: 'This remote session belongs to another server',
|
|
338
|
+
unsupported: 'This feature is not supported in remote sessions yet',
|
|
339
|
+
referenceNotOnServer: 'Referenced session is not on the server and cannot be forwarded',
|
|
340
|
+
revoked: 'Pairing token was revoked — please pair again in Settings',
|
|
341
|
+
unpaired: 'Not paired with the server yet — please pair in Settings',
|
|
342
|
+
modelGroupRemote: 'This model group belongs to a remote server and cannot be used in a local session',
|
|
343
|
+
modelGroupPickServer: (displayName) => `Select a model under "${displayName} · …"`,
|
|
344
|
+
modelGroupMismatch: 'This model group belongs to another server',
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
function resolveInterceptLang(lang) {
|
|
348
|
+
const value = typeof lang === 'function' ? lang() : lang;
|
|
349
|
+
return value === 'en' ? 'en' : 'zh';
|
|
350
|
+
}
|
|
307
351
|
/**
|
|
308
352
|
* The RelayError codes that mean "the LINK went down under a live stream"
|
|
309
353
|
* (CP4): `offline` is the transport death, `server-restart` the server's
|
|
@@ -449,6 +493,27 @@ function releaseUplink(uplink) {
|
|
|
449
493
|
// A non-iterable uplink has nothing to release.
|
|
450
494
|
}
|
|
451
495
|
}
|
|
496
|
+
/**
|
|
497
|
+
* The shape check one duplex stream's `uplinkWindow` must pass before it
|
|
498
|
+
* travels (TA-2) — the same rule the server re-applies on `/v1/stream`
|
|
499
|
+
* (relay-server.ts): positive safe-integer `maxBytes`, non-negative
|
|
500
|
+
* safe-integer `frameOverheadBytes`. Anything else — an older host's
|
|
501
|
+
* `undefined`, a non-object, a fractional or negative budget — comes back
|
|
502
|
+
* `undefined` and the stream forwards with the relay's own default window:
|
|
503
|
+
* the numbers are the host's contract with itself, garbage is dropped, never
|
|
504
|
+
* refused.
|
|
505
|
+
*/
|
|
506
|
+
function normalizeUplinkWindow(raw) {
|
|
507
|
+
if (!isPlainObject(raw))
|
|
508
|
+
return undefined;
|
|
509
|
+
const { maxBytes, frameOverheadBytes } = raw;
|
|
510
|
+
if (typeof maxBytes !== 'number' || !Number.isSafeInteger(maxBytes) || maxBytes <= 0)
|
|
511
|
+
return undefined;
|
|
512
|
+
if (typeof frameOverheadBytes !== 'number' || !Number.isSafeInteger(frameOverheadBytes) || frameOverheadBytes < 0) {
|
|
513
|
+
return undefined;
|
|
514
|
+
}
|
|
515
|
+
return { maxBytes, frameOverheadBytes };
|
|
516
|
+
}
|
|
452
517
|
/**
|
|
453
518
|
* The gateway error codes that count as version-mismatch symptoms, verified
|
|
454
519
|
* against dsh-api-gateway 0.2.0-rc.1 (`lib/index.js`):
|
|
@@ -1128,6 +1193,7 @@ async function* mergedGlobalStream(deps) {
|
|
|
1128
1193
|
serverName: initial?.serverName ?? '',
|
|
1129
1194
|
onDiagnostic,
|
|
1130
1195
|
now: clock.now,
|
|
1196
|
+
lang: deps.lang,
|
|
1131
1197
|
...(sessionSlot !== undefined ? { onKnownSessions: (ids) => sessionSlot.replace(ids) } : {}),
|
|
1132
1198
|
});
|
|
1133
1199
|
/**
|
|
@@ -1188,7 +1254,14 @@ async function* mergedGlobalStream(deps) {
|
|
|
1188
1254
|
// away, and a navigation writes no relay frames. An `unavailable` read is
|
|
1189
1255
|
// skipped entirely: the merger falls back to the conservative tombstone
|
|
1190
1256
|
// behavior then.
|
|
1257
|
+
let lastLang = resolveInterceptLang(deps.lang);
|
|
1191
1258
|
const informCurrentSession = () => {
|
|
1259
|
+
const nextLang = resolveInterceptLang(deps.lang);
|
|
1260
|
+
if (nextLang !== lastLang) {
|
|
1261
|
+
lastLang = nextLang;
|
|
1262
|
+
for (const frame of merger.onStatusChanged())
|
|
1263
|
+
channel.push(frame);
|
|
1264
|
+
}
|
|
1192
1265
|
const read = deps.getCurrentSession();
|
|
1193
1266
|
if (read.kind === 'unavailable')
|
|
1194
1267
|
return;
|
|
@@ -2229,256 +2302,1545 @@ async function* mergedEventsStream(deps) {
|
|
|
2229
2302
|
}
|
|
2230
2303
|
}
|
|
2231
2304
|
/**
|
|
2232
|
-
*
|
|
2233
|
-
*
|
|
2234
|
-
*
|
|
2235
|
-
*
|
|
2305
|
+
* The pool-mode merged global stream. Per server slot the same rules as the
|
|
2306
|
+
* single-server merged stream apply; the slots' outputs differ only in the
|
|
2307
|
+
* GLOBAL frames (`order` / `archived` / `pinned` and the baseline's merged
|
|
2308
|
+
* lists), which are never taken from a slot's own merger — they are
|
|
2309
|
+
* composed canonically from every slot's contribution in pairing order
|
|
2310
|
+
* (composeMergedWorkspaceFrames). Per-workspace frames (upserts and
|
|
2311
|
+
* removes) are slot-local facts and pass as the merger produced them.
|
|
2236
2312
|
*/
|
|
2237
|
-
|
|
2238
|
-
const {
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
const summarySync = createSessionSummarySync();
|
|
2245
|
-
// PB-25b: the inventory slot factory (the wiring's union feed) — passed
|
|
2246
|
-
// down to every merged stream, which owns one slot for its lifetime.
|
|
2247
|
-
const createSessionInventorySlot = options.createSessionInventorySlot;
|
|
2248
|
-
// T62: the current-session getter and the live-merged-stream listener
|
|
2249
|
-
// registry behind handle.currentSessionChanged().
|
|
2250
|
-
const getCurrentSession = options.getCurrentSession ?? (() => ({ kind: 'unavailable' }));
|
|
2251
|
-
const currentSessionListeners = new Set();
|
|
2252
|
-
const watchCurrentSession = (fn) => {
|
|
2253
|
-
currentSessionListeners.add(fn);
|
|
2254
|
-
return () => { currentSessionListeners.delete(fn); };
|
|
2313
|
+
async function* mergedGlobalStreamMulti(deps) {
|
|
2314
|
+
const { endpoint, namespace, method, local, pool, signal, recordFailure, log, clock } = deps;
|
|
2315
|
+
if (signal?.aborted === true)
|
|
2316
|
+
return;
|
|
2317
|
+
const isWorkspace = endpoint === 'workspace/follow';
|
|
2318
|
+
const onDiagnostic = (message) => {
|
|
2319
|
+
log?.('client multi merge dropped a frame on %s: %s', endpoint, message);
|
|
2255
2320
|
};
|
|
2256
|
-
const
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2321
|
+
const channel = createFrameChannel();
|
|
2322
|
+
let alive = true;
|
|
2323
|
+
const slots = new Map();
|
|
2324
|
+
const onlineWaiters = [];
|
|
2325
|
+
const flushWaiters = () => {
|
|
2326
|
+
for (const wake of onlineWaiters.splice(0))
|
|
2327
|
+
wake();
|
|
2260
2328
|
};
|
|
2261
|
-
const
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
// The
|
|
2267
|
-
//
|
|
2268
|
-
//
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
// `groups` membership set is gone with the gate.
|
|
2287
|
-
let serverCatalogCache;
|
|
2288
|
-
/**
|
|
2289
|
-
* The serving generation, bumped on every pairing wall (`unpaired` /
|
|
2290
|
-
* `revoked`, T52-fix3): a catalog fetch that was IN FLIGHT when the wall
|
|
2291
|
-
* rose must not write its answer back — the wall's groups must not outlive
|
|
2292
|
-
* it, and a late write would resurrect them under the offline merge below.
|
|
2293
|
-
* Both cache writes (the proactive fetch here, the route's own fetch)
|
|
2294
|
-
* snapshot the counter before invoking and compare before storing; a
|
|
2295
|
-
* mismatched answer is silently dropped — it is not a fault, the next
|
|
2296
|
-
* serving period fetches fresh anyway.
|
|
2297
|
-
*/
|
|
2298
|
-
let catalogGeneration = 0;
|
|
2299
|
-
const rememberServerCatalog = (identity, value, generation) => {
|
|
2300
|
-
if (generation !== catalogGeneration)
|
|
2329
|
+
const waitAny = async () => {
|
|
2330
|
+
await new Promise((resolve) => {
|
|
2331
|
+
onlineWaiters.push(resolve);
|
|
2332
|
+
});
|
|
2333
|
+
};
|
|
2334
|
+
// The orchestrator's LOCAL truth, folded from every local frame — the one
|
|
2335
|
+
// source the canonical composition reads its local lists from (a slot's
|
|
2336
|
+
// merger keeps its own copy only for its discarded single-server frames).
|
|
2337
|
+
let localSeen = false;
|
|
2338
|
+
let localBaselineFrame = undefined;
|
|
2339
|
+
let localLists = { orderIds: [], archivedIds: [], pinnedIds: [] };
|
|
2340
|
+
const annotationOf = (client) => {
|
|
2341
|
+
const state = client.state;
|
|
2342
|
+
if (state === 'revoked')
|
|
2343
|
+
return 'revoked';
|
|
2344
|
+
if (state === 'unpaired')
|
|
2345
|
+
return 'unpaired';
|
|
2346
|
+
if (state !== 'online')
|
|
2347
|
+
return 'offline';
|
|
2348
|
+
const different = client.compat?.different;
|
|
2349
|
+
return different !== undefined && different.length > 0 ? 'mismatch' : 'none';
|
|
2350
|
+
};
|
|
2351
|
+
const workspaceMergerOf = (slot) => slot.kind === 'workspace' ? slot.merger : undefined;
|
|
2352
|
+
const pushOut = (frames) => {
|
|
2353
|
+
if (frames.length === 0)
|
|
2301
2354
|
return;
|
|
2302
|
-
|
|
2355
|
+
for (const frame of frames)
|
|
2356
|
+
channel.push(frame);
|
|
2357
|
+
if (isWorkspace) {
|
|
2358
|
+
const ids = virtualSessionIdsInWorkspaceFrames(frames);
|
|
2359
|
+
if (ids.length > 0)
|
|
2360
|
+
deps.summarySync.noteForwardedSessionIds(ids);
|
|
2361
|
+
}
|
|
2303
2362
|
};
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2363
|
+
const orderedRemoteParts = () => {
|
|
2364
|
+
const out = [];
|
|
2365
|
+
if (!isWorkspace)
|
|
2366
|
+
return out;
|
|
2367
|
+
for (const entry of pool.entries()) {
|
|
2368
|
+
if (entry.serverId === undefined)
|
|
2369
|
+
continue;
|
|
2370
|
+
const slot = slots.get(entry.serverId);
|
|
2371
|
+
const merger = slot !== undefined ? workspaceMergerOf(slot) : undefined;
|
|
2372
|
+
if (slot === undefined || merger === undefined)
|
|
2373
|
+
continue;
|
|
2374
|
+
out.push({ slot, remote: merger.parts().remote });
|
|
2375
|
+
}
|
|
2376
|
+
return out;
|
|
2318
2377
|
};
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2378
|
+
/** The canonical merged global frames over ALL current slots — pairing
|
|
2379
|
+
* order, local first. Only past the local baseline: before it the slots
|
|
2380
|
+
* cache their remote state and the frames would order workspaces the
|
|
2381
|
+
* local baseline would immediately erase. */
|
|
2382
|
+
const composedFrames = () => {
|
|
2383
|
+
if (!localSeen || !isWorkspace)
|
|
2384
|
+
return undefined;
|
|
2385
|
+
const parts = orderedRemoteParts();
|
|
2386
|
+
return composeMergedWorkspaceFrames(localLists, parts.map((part) => part.remote));
|
|
2387
|
+
};
|
|
2388
|
+
const pushCanonicalTrio = () => {
|
|
2389
|
+
const composed = composedFrames();
|
|
2390
|
+
if (composed !== undefined)
|
|
2391
|
+
pushOut([composed.order, composed.archived, composed.pinned]);
|
|
2392
|
+
};
|
|
2393
|
+
// -- current-session judging (T58/T62), across every slot ------------------
|
|
2394
|
+
let lastLang = resolveInterceptLang(deps.lang);
|
|
2395
|
+
const informCurrentSession = () => {
|
|
2396
|
+
const nextLang = resolveInterceptLang(deps.lang);
|
|
2397
|
+
if (nextLang !== lastLang) {
|
|
2398
|
+
lastLang = nextLang;
|
|
2399
|
+
if (isWorkspace) {
|
|
2400
|
+
let langUpserted = false;
|
|
2401
|
+
for (const slot of [...slots.values()]) {
|
|
2402
|
+
const merger = workspaceMergerOf(slot);
|
|
2403
|
+
if (merger === undefined)
|
|
2404
|
+
continue;
|
|
2405
|
+
const frames = merger.onStatusChanged();
|
|
2406
|
+
if (frames.length > 0) {
|
|
2407
|
+
pushOut(frames.filter((frame) => isPlainObject(frame) && frame.type === 'upsert'));
|
|
2408
|
+
langUpserted = true;
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
if (langUpserted)
|
|
2412
|
+
pushCanonicalTrio();
|
|
2337
2413
|
}
|
|
2338
|
-
if (state !== 'online')
|
|
2339
|
-
return;
|
|
2340
|
-
const identity = relayIdentityOf(relay);
|
|
2341
|
-
if (identity !== undefined)
|
|
2342
|
-
fetchServerCatalog(identity);
|
|
2343
|
-
});
|
|
2344
|
-
// Install into an already-serving relay: subscribe replayed nothing, so
|
|
2345
|
-
// the transition-based fetch would wait for the NEXT flap — fill the
|
|
2346
|
-
// cache now instead (T52-fix3).
|
|
2347
|
-
if (relay.state === 'online') {
|
|
2348
|
-
const identity = relayIdentityOf(relay);
|
|
2349
|
-
if (identity !== undefined)
|
|
2350
|
-
fetchServerCatalog(identity);
|
|
2351
2414
|
}
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
closedSessions.delete(sessionId);
|
|
2360
|
-
if (closedSessions.size >= MAX_CLOSED_SESSIONS) {
|
|
2361
|
-
const oldest = closedSessions.keys().next();
|
|
2362
|
-
if (oldest.done !== true)
|
|
2363
|
-
closedSessions.delete(oldest.value);
|
|
2415
|
+
const read = deps.getCurrentSession();
|
|
2416
|
+
if (read.kind === 'unavailable')
|
|
2417
|
+
return;
|
|
2418
|
+
let changed = false;
|
|
2419
|
+
for (const slot of [...slots.values()]) {
|
|
2420
|
+
if (slot.merger.setCurrentSession(read.kind === 'open' ? read.sessionId : undefined).length > 0)
|
|
2421
|
+
changed = true;
|
|
2364
2422
|
}
|
|
2365
|
-
|
|
2423
|
+
if (!changed || !localSeen || !isWorkspace)
|
|
2424
|
+
return;
|
|
2425
|
+
const parts = orderedRemoteParts();
|
|
2426
|
+
pushOut([composeMergedWorkspaceFrames(localLists, parts.map((part) => part.remote)).archived]);
|
|
2366
2427
|
};
|
|
2367
|
-
const
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
cleared = true;
|
|
2428
|
+
const anyPendingHide = () => {
|
|
2429
|
+
for (const slot of [...slots.values()]) {
|
|
2430
|
+
if (slot.merger.hasPendingHide)
|
|
2431
|
+
return true;
|
|
2372
2432
|
}
|
|
2373
|
-
|
|
2374
|
-
for (const waiter of [...closedClearWaiters])
|
|
2375
|
-
waiter();
|
|
2433
|
+
return false;
|
|
2376
2434
|
};
|
|
2377
|
-
let
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
* a NEW wrapper while an OLD one is still installed, so each captures the
|
|
2382
|
-
* other and the older survives as a live layer. Cordis `fiber.restart()`
|
|
2383
|
-
* disposes the old fiber FIRST (its effect calls {@link InterceptHandle.uninstall},
|
|
2384
|
-
* restoring the saved property) and only then applies the new row — the
|
|
2385
|
-
* overlay ordering never happens today, so there is nothing to absorb at
|
|
2386
|
-
* install time. Detection is also not cheaply implementable: a reloaded row
|
|
2387
|
-
* loads a FRESH module instance, so a stale wrapper shares no mark with the
|
|
2388
|
-
* new one — telling "ours, from a dead generation" apart from another
|
|
2389
|
-
* plugin's wrapper would take a process-wide registry, which the comment
|
|
2390
|
-
* above argues is not worth it while the ordering cannot occur. The
|
|
2391
|
-
* `installed === false → inert passthrough` rule below is the safety net
|
|
2392
|
-
* for the ordering that CAN happen: a later plugin wrapping OVER us and
|
|
2393
|
-
* keeping a reference past our uninstall.
|
|
2394
|
-
*
|
|
2395
|
-
* Save the CURRENT value per property (possibly another plugin's wrapper —
|
|
2396
|
-
* shape note "own-property"), falling back to the prototype method only
|
|
2397
|
-
* when the current value is not callable. Uninstall restores the saved
|
|
2398
|
-
* own state verbatim: value if there was one, absence if not.
|
|
2399
|
-
*/
|
|
2400
|
-
const saved = {};
|
|
2401
|
-
const chainTarget = {};
|
|
2402
|
-
for (const name of ['openWireStream', 'dispatchRpc']) {
|
|
2403
|
-
const existed = Object.prototype.hasOwnProperty.call(gateway, name);
|
|
2404
|
-
const value = existed ? gateway[name] : undefined;
|
|
2405
|
-
saved[name] = { existed, value };
|
|
2406
|
-
chainTarget[name] = typeof value === 'function' ? value : Object.getPrototypeOf(raw)?.[name];
|
|
2407
|
-
}
|
|
2408
|
-
// Filled in below, once the wrapper functions exist: uninstall may only
|
|
2409
|
-
// touch a property that still holds OUR wrapper.
|
|
2410
|
-
const wrappers = {};
|
|
2411
|
-
const recordFailure = (endpoint, code) => {
|
|
2412
|
-
if (failures.length >= MAX_FAILURES)
|
|
2413
|
-
failures.shift();
|
|
2414
|
-
failures.push({ time: new Date().toISOString(), endpoint, code });
|
|
2415
|
-
// T42: validation-shaped refusals additionally land in the incompatible
|
|
2416
|
-
// ring — every remote failure funnels through this one function, so the
|
|
2417
|
-
// classification cannot drift between the invoke and stream routes.
|
|
2418
|
-
if (!INCOMPATIBLE_CALL_CODES.has(code))
|
|
2435
|
+
let sessionPoll = undefined;
|
|
2436
|
+
const pollCurrentSession = () => {
|
|
2437
|
+
sessionPoll = undefined;
|
|
2438
|
+
if (!alive)
|
|
2419
2439
|
return;
|
|
2420
|
-
if (
|
|
2421
|
-
|
|
2422
|
-
|
|
2440
|
+
if (anyPendingHide())
|
|
2441
|
+
informCurrentSession();
|
|
2442
|
+
if (alive && anyPendingHide())
|
|
2443
|
+
sessionPoll = clock.setTimeout(pollCurrentSession, CURRENT_SESSION_POLL_MS);
|
|
2423
2444
|
};
|
|
2424
|
-
const
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
// gateway's rpcErrorSchema demands the same shape).
|
|
2429
|
-
return { ok: false, error: { code, message: text, details: {} } };
|
|
2445
|
+
const ensureSessionPoll = () => {
|
|
2446
|
+
if (sessionPoll === undefined && alive && anyPendingHide()) {
|
|
2447
|
+
sessionPoll = clock.setTimeout(pollCurrentSession, CURRENT_SESSION_POLL_MS);
|
|
2448
|
+
}
|
|
2430
2449
|
};
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2450
|
+
// -- slot lifecycle ----------------------------------------------------------
|
|
2451
|
+
const removeSlot = (serverId, slot) => {
|
|
2452
|
+
slots.delete(serverId);
|
|
2453
|
+
slot.removed = true;
|
|
2454
|
+
slot.offState();
|
|
2455
|
+
slot.controller?.abort();
|
|
2456
|
+
if (slot.inventory !== undefined) {
|
|
2457
|
+
slot.inventory.dispose();
|
|
2458
|
+
slot.inventory = undefined;
|
|
2438
2459
|
}
|
|
2439
|
-
|
|
2440
|
-
|
|
2460
|
+
// Only THIS server's shown groups leave; the canonical trio then re-airs
|
|
2461
|
+
// the merged order/archived/pinned over the surviving slots (the
|
|
2462
|
+
// merger's own trailing local-only restores are superseded by it).
|
|
2463
|
+
if (!isWorkspace)
|
|
2464
|
+
return;
|
|
2465
|
+
const merger = slot.merger;
|
|
2466
|
+
const gone = merger.onRemoteGone();
|
|
2467
|
+
pushOut(gone.filter((frame) => isPlainObject(frame) && frame.type === 'remove'));
|
|
2468
|
+
pushCanonicalTrio();
|
|
2469
|
+
};
|
|
2470
|
+
const applySlotAnnotation = (slot, next) => {
|
|
2471
|
+
if (next === slot.annotation)
|
|
2472
|
+
return;
|
|
2473
|
+
slot.annotation = next;
|
|
2474
|
+
slot.merger.setStatus(next);
|
|
2475
|
+
if (!isWorkspace)
|
|
2476
|
+
return;
|
|
2477
|
+
const merger = slot.merger;
|
|
2478
|
+
const frames = merger.onStatusChanged();
|
|
2479
|
+
if (frames.length > 0) {
|
|
2480
|
+
pushOut(frames.filter((frame) => isPlainObject(frame) && frame.type === 'upsert'));
|
|
2481
|
+
pushCanonicalTrio();
|
|
2441
2482
|
}
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
* gateway's business.
|
|
2454
|
-
*/
|
|
2455
|
-
function forwardEventResult(payload, signal, peer) {
|
|
2456
|
-
const args = argsOf(payload);
|
|
2457
|
-
const eventId = isPlainObject(args) ? args.eventId : undefined;
|
|
2458
|
-
const virtual = fromVirtual(eventId);
|
|
2459
|
-
if (virtual === undefined) {
|
|
2460
|
-
return chainTarget.dispatchRpc.call(raw, '$events/result', payload, signal, peer);
|
|
2483
|
+
};
|
|
2484
|
+
const refreshOneViewName = (slot, nextName) => {
|
|
2485
|
+
slot.viewName = nextName;
|
|
2486
|
+
slot.merger.retarget({ serverId: slot.serverId, serverName: nextName });
|
|
2487
|
+
if (!isWorkspace)
|
|
2488
|
+
return;
|
|
2489
|
+
const merger = slot.merger;
|
|
2490
|
+
const frames = merger.onServerRenamed();
|
|
2491
|
+
if (frames.length > 0) {
|
|
2492
|
+
pushOut(frames);
|
|
2493
|
+
pushCanonicalTrio();
|
|
2461
2494
|
}
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2495
|
+
};
|
|
2496
|
+
const refreshViewNames = () => {
|
|
2497
|
+
for (const entry of pool.entries()) {
|
|
2498
|
+
const slot = entry.serverId !== undefined ? slots.get(entry.serverId) : undefined;
|
|
2499
|
+
if (slot !== undefined && slot.viewName !== entry.displayName)
|
|
2500
|
+
refreshOneViewName(slot, entry.displayName);
|
|
2501
|
+
}
|
|
2502
|
+
};
|
|
2503
|
+
const pumpSlot = async (slot) => {
|
|
2504
|
+
// Whole body guarded: ANY escape stops THIS server's leg only.
|
|
2505
|
+
try {
|
|
2506
|
+
while (alive && !slot.removed) {
|
|
2507
|
+
if (slot.client.state !== 'online') {
|
|
2508
|
+
await waitAny();
|
|
2509
|
+
continue;
|
|
2510
|
+
}
|
|
2511
|
+
const controller = new AbortController();
|
|
2512
|
+
slot.controller = controller;
|
|
2513
|
+
try {
|
|
2514
|
+
const stream = slot.client.openStream(namespace, method, {}, controller.signal);
|
|
2515
|
+
let served = false;
|
|
2516
|
+
for await (const frame of stream) {
|
|
2517
|
+
// Post-abort lines the transport already decoded must not reach
|
|
2518
|
+
// the merger (same discipline as the single-server pumps).
|
|
2519
|
+
if (!alive || slot.removed || controller.signal.aborted)
|
|
2520
|
+
break;
|
|
2521
|
+
if (!served) {
|
|
2522
|
+
served = true;
|
|
2523
|
+
slot.reopenDelayMs = REMOTE_REOPEN_FIRST_MS;
|
|
2524
|
+
}
|
|
2525
|
+
informCurrentSession();
|
|
2526
|
+
const out = slot.merger.onRemote(frame);
|
|
2527
|
+
if (out.length > 0) {
|
|
2528
|
+
if (isWorkspace) {
|
|
2529
|
+
pushOut(out.filter((outFrame) => isPlainObject(outFrame) && (outFrame.type === 'upsert' || outFrame.type === 'remove')));
|
|
2530
|
+
pushCanonicalTrio();
|
|
2531
|
+
}
|
|
2532
|
+
else {
|
|
2533
|
+
pushOut(out);
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
ensureSessionPoll();
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
catch (error) {
|
|
2540
|
+
if (alive && !slot.removed && !controller.signal.aborted && signal?.aborted !== true) {
|
|
2541
|
+
recordFailure(endpoint, error instanceof RelayError ? error.code : 'internal');
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
finally {
|
|
2545
|
+
if (slot.controller === controller)
|
|
2546
|
+
slot.controller = undefined;
|
|
2547
|
+
}
|
|
2548
|
+
if (!alive || slot.removed)
|
|
2549
|
+
return;
|
|
2550
|
+
slot.merger.onRemoteDown();
|
|
2551
|
+
if (slot.client.state === 'online') {
|
|
2552
|
+
await waitReopen(clock, waitAny, slot.reopenDelayMs);
|
|
2553
|
+
slot.reopenDelayMs = Math.min(slot.reopenDelayMs * 2, REMOTE_REOPEN_MAX_MS);
|
|
2554
|
+
continue;
|
|
2555
|
+
}
|
|
2556
|
+
await waitAny();
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
catch (error) {
|
|
2560
|
+
if (alive)
|
|
2561
|
+
onDiagnostic(`remote leg of ${slot.serverId} stopped: ${messageOf(error)}`);
|
|
2562
|
+
}
|
|
2563
|
+
};
|
|
2564
|
+
/** Re-read pool.entries(): create slots for new handshook servers (in
|
|
2565
|
+
* pairing order), refresh display names, remove slots whose server left.
|
|
2566
|
+
* Also heals a re-handshake that renamed the entry's serverId. */
|
|
2567
|
+
const scanSlots = () => {
|
|
2568
|
+
const seen = new Set();
|
|
2569
|
+
for (const entry of pool.entries()) {
|
|
2570
|
+
const serverId = entry.serverId;
|
|
2571
|
+
if (serverId === undefined)
|
|
2572
|
+
continue;
|
|
2573
|
+
seen.add(serverId);
|
|
2574
|
+
const existing = slots.get(serverId);
|
|
2575
|
+
if (existing !== undefined) {
|
|
2576
|
+
if (existing.viewName !== entry.displayName)
|
|
2577
|
+
refreshOneViewName(existing, entry.displayName);
|
|
2578
|
+
continue;
|
|
2579
|
+
}
|
|
2580
|
+
const inventory = isWorkspace && deps.createSessionInventorySlot !== undefined
|
|
2581
|
+
? deps.createSessionInventorySlot(serverId)
|
|
2582
|
+
: undefined;
|
|
2583
|
+
const merger = isWorkspace
|
|
2584
|
+
? createWorkspaceMerger({
|
|
2585
|
+
serverId,
|
|
2586
|
+
serverName: entry.displayName,
|
|
2587
|
+
onDiagnostic,
|
|
2588
|
+
now: clock.now,
|
|
2589
|
+
lang: deps.lang,
|
|
2590
|
+
...(inventory !== undefined ? { onKnownSessions: (ids) => inventory.replace(ids) } : {}),
|
|
2591
|
+
})
|
|
2592
|
+
: createControlMerger({ serverId, serverName: entry.displayName, onDiagnostic });
|
|
2593
|
+
const slot = {
|
|
2594
|
+
kind: isWorkspace ? 'workspace' : 'control',
|
|
2595
|
+
serverId,
|
|
2596
|
+
entryId: entry.entryId,
|
|
2597
|
+
client: entry.client,
|
|
2598
|
+
merger,
|
|
2599
|
+
viewName: entry.displayName,
|
|
2600
|
+
controller: undefined,
|
|
2601
|
+
offState: () => { },
|
|
2602
|
+
annotation: annotationOf(entry.client),
|
|
2603
|
+
reopenDelayMs: REMOTE_REOPEN_FIRST_MS,
|
|
2604
|
+
removed: false,
|
|
2605
|
+
inventory,
|
|
2606
|
+
};
|
|
2607
|
+
merger.setStatus(slot.annotation);
|
|
2608
|
+
slot.offState = slot.client.subscribe((state) => {
|
|
2609
|
+
try {
|
|
2610
|
+
// The set itself may have changed (a dedupe renamed the entry's
|
|
2611
|
+
// serverId, a rename moved the display name) — re-scan first.
|
|
2612
|
+
scanSlots();
|
|
2613
|
+
if (state === 'unpaired' || state === 'revoked') {
|
|
2614
|
+
applySlotAnnotation(slot, annotationOf(slot.client));
|
|
2615
|
+
slot.merger.onRemoteDown();
|
|
2616
|
+
slot.controller?.abort();
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
applySlotAnnotation(slot, annotationOf(slot.client));
|
|
2620
|
+
if (state === 'online')
|
|
2621
|
+
refreshViewNames();
|
|
2622
|
+
}
|
|
2623
|
+
catch (error) {
|
|
2624
|
+
log?.('client multi merge slot state handler failed on %s: %s', serverId, messageOf(error));
|
|
2625
|
+
}
|
|
2626
|
+
finally {
|
|
2627
|
+
flushWaiters();
|
|
2628
|
+
}
|
|
2629
|
+
});
|
|
2630
|
+
slots.set(serverId, slot);
|
|
2631
|
+
// A slot born mid-stream missed the local baseline — replay it (its
|
|
2632
|
+
// output is discarded; the replay only sets the merger's localSeen and
|
|
2633
|
+
// local lists). Runs BEFORE the pump starts, so the buffer is empty.
|
|
2634
|
+
if (localBaselineFrame !== undefined) {
|
|
2635
|
+
for (const out of slot.merger.onLocal(localBaselineFrame))
|
|
2636
|
+
void out;
|
|
2637
|
+
}
|
|
2638
|
+
void pumpSlot(slot);
|
|
2639
|
+
}
|
|
2640
|
+
for (const [serverId, slot] of [...slots]) {
|
|
2641
|
+
if (!seen.has(serverId))
|
|
2642
|
+
removeSlot(serverId, slot);
|
|
2643
|
+
}
|
|
2644
|
+
};
|
|
2645
|
+
// -- local frames -------------------------------------------------------------
|
|
2646
|
+
const pushLocalWorkspace = (frame) => {
|
|
2647
|
+
localLists = localWorkspaceLists(frame, localLists);
|
|
2648
|
+
// Every slot's merger must observe the local frames too — its localSeen
|
|
2649
|
+
// gate (nothing leaves its onRemote before the local baseline passed)
|
|
2650
|
+
// and its own local lists live there. Its OUTPUT is discarded: the
|
|
2651
|
+
// single-server global frames (its merged order/archived/pinned) are
|
|
2652
|
+
// superseded by the canonical composition over ALL slots.
|
|
2653
|
+
for (const slot of [...slots.values()]) {
|
|
2654
|
+
for (const out of slot.merger.onLocal(frame))
|
|
2655
|
+
void out;
|
|
2656
|
+
}
|
|
2657
|
+
if (!isPlainObject(frame)) {
|
|
2658
|
+
pushOut([frame]);
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
if (frame.type === 'baseline') {
|
|
2662
|
+
localSeen = true;
|
|
2663
|
+
localBaselineFrame = frame;
|
|
2664
|
+
const parts = orderedRemoteParts();
|
|
2665
|
+
const remotes = parts.map((part) => part.remote);
|
|
2666
|
+
const hasRemote = remotes.some((remote) => remote.orderIds.length + remote.archivedIds.length + remote.pinnedIds.length + remote.workspaces.length > 0);
|
|
2667
|
+
if (!hasRemote) {
|
|
2668
|
+
pushOut([frame]);
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
const value = isPlainObject(frame.value) ? frame.value : {};
|
|
2672
|
+
const items = Array.isArray(value.items) ? value.items : [];
|
|
2673
|
+
const composed = composeMergedWorkspaceFrames(localLists, remotes);
|
|
2674
|
+
pushOut([
|
|
2675
|
+
{
|
|
2676
|
+
...frame,
|
|
2677
|
+
value: {
|
|
2678
|
+
...value,
|
|
2679
|
+
items: [...items, ...composed.workspaces],
|
|
2680
|
+
archivedSessionIds: composed.archived.archivedSessionIds,
|
|
2681
|
+
pinnedSessionIds: composed.pinned.pinnedSessionIds,
|
|
2682
|
+
},
|
|
2683
|
+
},
|
|
2684
|
+
...composed.workspaces.map((workspace) => ({ type: 'upsert', workspace })),
|
|
2685
|
+
]);
|
|
2686
|
+
return;
|
|
2687
|
+
}
|
|
2688
|
+
const remotes = orderedRemoteParts().map((part) => part.remote);
|
|
2689
|
+
if (frame.type === 'order') {
|
|
2690
|
+
if (!remotes.some((remote) => remote.orderIds.length > 0)) {
|
|
2691
|
+
pushOut([frame]);
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
pushOut([composeMergedWorkspaceFrames(localLists, remotes).order]);
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
if (frame.type === 'archived') {
|
|
2698
|
+
if (!remotes.some((remote) => remote.archivedIds.length > 0)) {
|
|
2699
|
+
pushOut([frame]);
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
pushOut([composeMergedWorkspaceFrames(localLists, remotes).archived]);
|
|
2703
|
+
return;
|
|
2704
|
+
}
|
|
2705
|
+
if (frame.type === 'pinned') {
|
|
2706
|
+
if (!remotes.some((remote) => remote.pinnedIds.length > 0)) {
|
|
2707
|
+
pushOut([frame]);
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
pushOut([composeMergedWorkspaceFrames(localLists, remotes).pinned]);
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
// upsert / remove / anything else local: purely local facts.
|
|
2714
|
+
pushOut([frame]);
|
|
2715
|
+
};
|
|
2716
|
+
const pushLocalControl = (frame) => {
|
|
2717
|
+
if (isPlainObject(frame) && frame.type === 'baseline') {
|
|
2718
|
+
pushOut([frame]);
|
|
2719
|
+
// Each slot's buffered pre-baseline projections flush behind it (the
|
|
2720
|
+
// slots' own baseline copies are the SAME frame object — skip those).
|
|
2721
|
+
for (const slot of [...slots.values()]) {
|
|
2722
|
+
for (const out of slot.merger.onLocal(frame)) {
|
|
2723
|
+
if (out !== frame)
|
|
2724
|
+
pushOut([out]);
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
pushOut([frame]);
|
|
2730
|
+
for (const slot of [...slots.values()])
|
|
2731
|
+
slot.merger.onLocal(frame);
|
|
2732
|
+
};
|
|
2733
|
+
const offCurrentSession = deps.watchCurrentSession(informCurrentSession);
|
|
2734
|
+
const offUninstall = deps.watchUninstall(() => {
|
|
2735
|
+
try {
|
|
2736
|
+
const read = deps.getCurrentSession();
|
|
2737
|
+
for (const slot of [...slots.values()]) {
|
|
2738
|
+
slot.merger.setCurrentSession(read.kind === 'open' ? read.sessionId : undefined);
|
|
2739
|
+
applySlotAnnotation(slot, 'unpaired');
|
|
2740
|
+
slot.merger.onRemoteDown();
|
|
2741
|
+
slot.controller?.abort();
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
catch {
|
|
2745
|
+
// ignore teardown errors
|
|
2746
|
+
}
|
|
2747
|
+
finally {
|
|
2748
|
+
flushWaiters();
|
|
2749
|
+
}
|
|
2750
|
+
});
|
|
2751
|
+
const offPool = deps.watchPool(() => {
|
|
2752
|
+
try {
|
|
2753
|
+
scanSlots();
|
|
2754
|
+
refreshViewNames();
|
|
2755
|
+
}
|
|
2756
|
+
catch (error) {
|
|
2757
|
+
log?.('client multi merge pool rescan failed on %s: %s', endpoint, messageOf(error));
|
|
2758
|
+
}
|
|
2759
|
+
finally {
|
|
2760
|
+
flushWaiters();
|
|
2761
|
+
}
|
|
2762
|
+
});
|
|
2763
|
+
const onExternalAbort = () => {
|
|
2764
|
+
alive = false;
|
|
2765
|
+
for (const slot of [...slots.values()])
|
|
2766
|
+
slot.controller?.abort();
|
|
2767
|
+
flushWaiters();
|
|
2768
|
+
channel.end();
|
|
2769
|
+
};
|
|
2770
|
+
if (signal !== undefined)
|
|
2771
|
+
signal.addEventListener('abort', onExternalAbort);
|
|
2772
|
+
scanSlots();
|
|
2773
|
+
informCurrentSession();
|
|
2774
|
+
const localIterator = local[Symbol.asyncIterator]();
|
|
2775
|
+
const pumpLocal = async () => {
|
|
2776
|
+
try {
|
|
2777
|
+
while (alive) {
|
|
2778
|
+
const result = await localIterator.next();
|
|
2779
|
+
if (result.done === true)
|
|
2780
|
+
break;
|
|
2781
|
+
informCurrentSession();
|
|
2782
|
+
if (isWorkspace)
|
|
2783
|
+
pushLocalWorkspace(result.value);
|
|
2784
|
+
else
|
|
2785
|
+
pushLocalControl(result.value);
|
|
2786
|
+
ensureSessionPoll();
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
catch (error) {
|
|
2790
|
+
// The local stream is the one the UI opened: its failure is the
|
|
2791
|
+
// merged stream's failure (a remote death never is).
|
|
2792
|
+
if (alive)
|
|
2793
|
+
channel.fail(error);
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
if (!alive)
|
|
2797
|
+
return;
|
|
2798
|
+
alive = false;
|
|
2799
|
+
for (const slot of [...slots.values()])
|
|
2800
|
+
slot.controller?.abort();
|
|
2801
|
+
flushWaiters();
|
|
2802
|
+
channel.end();
|
|
2803
|
+
};
|
|
2804
|
+
void pumpLocal();
|
|
2805
|
+
try {
|
|
2806
|
+
while (true) {
|
|
2807
|
+
const result = await channel.next();
|
|
2808
|
+
if (result.done === true)
|
|
2809
|
+
break;
|
|
2810
|
+
yield result.value;
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
finally {
|
|
2814
|
+
alive = false;
|
|
2815
|
+
if (sessionPoll !== undefined)
|
|
2816
|
+
clock.clearTimeout(sessionPoll);
|
|
2817
|
+
offCurrentSession();
|
|
2818
|
+
offUninstall();
|
|
2819
|
+
offPool();
|
|
2820
|
+
if (signal !== undefined)
|
|
2821
|
+
signal.removeEventListener('abort', onExternalAbort);
|
|
2822
|
+
for (const slot of [...slots.values()]) {
|
|
2823
|
+
slot.removed = true;
|
|
2824
|
+
slot.offState();
|
|
2825
|
+
slot.controller?.abort();
|
|
2826
|
+
slot.inventory?.dispose();
|
|
2827
|
+
}
|
|
2828
|
+
slots.clear();
|
|
2829
|
+
flushWaiters();
|
|
2830
|
+
void Promise.resolve(localIterator.return?.(undefined)).catch(() => { });
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
/**
|
|
2834
|
+
* The pool-mode merged `$events` stream: one `$zr/events` leg per pooled
|
|
2835
|
+
* server, each with its own orphan-close record. The T52 catalog refresh
|
|
2836
|
+
* fires whenever any slot's serving identity appears, changes, or leaves;
|
|
2837
|
+
* the T63/T65 summary sync and the T75 status sync fetch from the OWNING
|
|
2838
|
+
* server of each id (virtual ids carry the serverId), per slot on its own
|
|
2839
|
+
* serving periods.
|
|
2840
|
+
*/
|
|
2841
|
+
async function* mergedEventsStreamMulti(deps) {
|
|
2842
|
+
const { local, pool, signal, recordFailure, log, clock } = deps;
|
|
2843
|
+
if (signal?.aborted === true)
|
|
2844
|
+
return;
|
|
2845
|
+
const channel = createFrameChannel();
|
|
2846
|
+
let alive = true;
|
|
2847
|
+
const slots = new Map();
|
|
2848
|
+
const onlineWaiters = [];
|
|
2849
|
+
const flushWaiters = () => {
|
|
2850
|
+
for (const wake of onlineWaiters.splice(0))
|
|
2851
|
+
wake();
|
|
2852
|
+
};
|
|
2853
|
+
const waitAny = async () => {
|
|
2854
|
+
await new Promise((resolve) => {
|
|
2855
|
+
onlineWaiters.push(resolve);
|
|
2856
|
+
});
|
|
2857
|
+
};
|
|
2858
|
+
let localOpened = false;
|
|
2859
|
+
let deferredRefresh = false;
|
|
2860
|
+
const pushRefresh = () => {
|
|
2861
|
+
if (!localOpened) {
|
|
2862
|
+
deferredRefresh = true;
|
|
2863
|
+
return;
|
|
2864
|
+
}
|
|
2865
|
+
channel.push(catalogRefreshFrame());
|
|
2866
|
+
};
|
|
2867
|
+
const noteSlotIdentity = (slot) => {
|
|
2868
|
+
const key = slot.client.state === 'online' ? `${slot.serverId}\u0000${slot.displayName}` : undefined;
|
|
2869
|
+
if (key === slot.notedIdentity)
|
|
2870
|
+
return;
|
|
2871
|
+
slot.notedIdentity = key;
|
|
2872
|
+
if (key !== undefined)
|
|
2873
|
+
pushRefresh();
|
|
2874
|
+
};
|
|
2875
|
+
// -- T63/T65/T75 bookkeeping: global maps over VIRTUAL ids (globally
|
|
2876
|
+
// unique across servers), per-slot fetch rounds. Same discipline as the
|
|
2877
|
+
// single-server stream — see mergedEventsStream for the full story.
|
|
2878
|
+
const announced = new Set();
|
|
2879
|
+
const askAttempts = new Map();
|
|
2880
|
+
const askedForGood = new Set();
|
|
2881
|
+
const retryAt = new Map();
|
|
2882
|
+
const MAX_ASK_ATTEMPTS = 3;
|
|
2883
|
+
const ASK_RETRY_FIRST_MS = 2_000;
|
|
2884
|
+
const ASK_RETRY_SECOND_MS = 10_000;
|
|
2885
|
+
let retryTimer = undefined;
|
|
2886
|
+
let midFlightBatches = [];
|
|
2887
|
+
let askingNow = [];
|
|
2888
|
+
let summarySyncInFlight = false;
|
|
2889
|
+
let deferredSummarySync = false;
|
|
2890
|
+
const latestLiveStatus = new Map();
|
|
2891
|
+
let statusSeq = 0;
|
|
2892
|
+
const liveStatusSeq = new Map();
|
|
2893
|
+
const scheduleRetry = () => {
|
|
2894
|
+
if (retryTimer !== undefined || retryAt.size === 0)
|
|
2895
|
+
return;
|
|
2896
|
+
const earliest = Math.min(...retryAt.values());
|
|
2897
|
+
retryTimer = clock.setTimeout(() => {
|
|
2898
|
+
retryTimer = undefined;
|
|
2899
|
+
if (!alive)
|
|
2900
|
+
return;
|
|
2901
|
+
const nowMs = clock.now();
|
|
2902
|
+
const due = [];
|
|
2903
|
+
for (const [id, at] of retryAt) {
|
|
2904
|
+
if (at <= nowMs) {
|
|
2905
|
+
retryAt.delete(id);
|
|
2906
|
+
due.push(id);
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
if (due.length > 0)
|
|
2910
|
+
runSummarySync(due);
|
|
2911
|
+
}, Math.max(0, earliest - clock.now()));
|
|
2912
|
+
};
|
|
2913
|
+
const runSummarySync = (triggerIds = []) => {
|
|
2914
|
+
if (!alive)
|
|
2915
|
+
return;
|
|
2916
|
+
if (!localOpened) {
|
|
2917
|
+
deferredSummarySync = true;
|
|
2918
|
+
return;
|
|
2919
|
+
}
|
|
2920
|
+
if (summarySyncInFlight) {
|
|
2921
|
+
midFlightBatches.push(...triggerIds);
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
const rounds = [];
|
|
2925
|
+
if (triggerIds.length === 0) {
|
|
2926
|
+
for (const slot of [...slots.values()]) {
|
|
2927
|
+
if (slot.client.state === 'online')
|
|
2928
|
+
rounds.push({ slot, ids: [] });
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
else {
|
|
2932
|
+
for (const id of triggerIds) {
|
|
2933
|
+
const parts = fromVirtual(id);
|
|
2934
|
+
if (parts === undefined)
|
|
2935
|
+
continue;
|
|
2936
|
+
const slot = slots.get(parts.serverId);
|
|
2937
|
+
if (slot === undefined || slot.client.state !== 'online')
|
|
2938
|
+
continue;
|
|
2939
|
+
let round = rounds.find((candidate) => candidate.slot === slot);
|
|
2940
|
+
if (round === undefined) {
|
|
2941
|
+
round = { slot, ids: [] };
|
|
2942
|
+
rounds.push(round);
|
|
2943
|
+
}
|
|
2944
|
+
round.ids.push(id);
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
if (rounds.length === 0)
|
|
2948
|
+
return;
|
|
2949
|
+
summarySyncInFlight = true;
|
|
2950
|
+
askingNow = [...triggerIds];
|
|
2951
|
+
for (const round of rounds) {
|
|
2952
|
+
for (const id of round.ids)
|
|
2953
|
+
askAttempts.set(id, (askAttempts.get(id) ?? 0) + 1);
|
|
2954
|
+
}
|
|
2955
|
+
void Promise.all(rounds.map((round) => round.slot.client
|
|
2956
|
+
.invoke('session', 'list', { _request: {} }, undefined)
|
|
2957
|
+
.then((value) => ({ round, value, error: undefined }), (error) => ({ round, value: undefined, error })))).then((results) => {
|
|
2958
|
+
summarySyncInFlight = false;
|
|
2959
|
+
if (!alive)
|
|
2960
|
+
return;
|
|
2961
|
+
askingNow = [];
|
|
2962
|
+
for (const result of results) {
|
|
2963
|
+
if (result.error !== undefined) {
|
|
2964
|
+
recordFailure('session/list', result.error instanceof RelayError ? result.error.code : 'internal');
|
|
2965
|
+
continue;
|
|
2966
|
+
}
|
|
2967
|
+
const frames = sessionSummaryAddedFrames(result.value, result.round.slot.serverId);
|
|
2968
|
+
const returned = new Set();
|
|
2969
|
+
for (const frame of frames) {
|
|
2970
|
+
const row = frame.args[0];
|
|
2971
|
+
const rowId = isPlainObject(row) && typeof row.sessionId === 'string' ? row.sessionId : undefined;
|
|
2972
|
+
if (rowId === undefined)
|
|
2973
|
+
continue;
|
|
2974
|
+
returned.add(rowId);
|
|
2975
|
+
if (announced.has(rowId))
|
|
2976
|
+
continue;
|
|
2977
|
+
announced.add(rowId);
|
|
2978
|
+
channel.push(frame);
|
|
2979
|
+
const liveRunning = latestLiveStatus.get(rowId);
|
|
2980
|
+
if (liveRunning !== undefined && liveRunning !== (isPlainObject(row) && row.running === true)) {
|
|
2981
|
+
channel.push({ type: 'emit', event: 'api-session/status', args: [rowId, liveRunning] });
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
for (const id of result.round.ids) {
|
|
2985
|
+
if (returned.has(id)) {
|
|
2986
|
+
retryAt.delete(id);
|
|
2987
|
+
continue;
|
|
2988
|
+
}
|
|
2989
|
+
const attempts = askAttempts.get(id) ?? 0;
|
|
2990
|
+
if (attempts >= MAX_ASK_ATTEMPTS)
|
|
2991
|
+
askedForGood.add(id);
|
|
2992
|
+
else
|
|
2993
|
+
retryAt.set(id, clock.now() + (attempts <= 1 ? ASK_RETRY_FIRST_MS : ASK_RETRY_SECOND_MS));
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
scheduleRetry();
|
|
2997
|
+
const queued = midFlightBatches;
|
|
2998
|
+
midFlightBatches = [];
|
|
2999
|
+
if (queued.length > 0)
|
|
3000
|
+
runSummarySync(queued);
|
|
3001
|
+
});
|
|
3002
|
+
};
|
|
3003
|
+
const handleForwardedBatch = (ids) => {
|
|
3004
|
+
if (!ids.some((id) => !announced.has(id) && !askedForGood.has(id) && !retryAt.has(id)))
|
|
3005
|
+
return;
|
|
3006
|
+
runSummarySync(ids);
|
|
3007
|
+
};
|
|
3008
|
+
/** T75 per slot: re-push every row's running flag from a fresh snapshot
|
|
3009
|
+
* of THIS server when its serving period starts. */
|
|
3010
|
+
const runStatusSync = (slot) => {
|
|
3011
|
+
if (!alive)
|
|
3012
|
+
return;
|
|
3013
|
+
if (!localOpened) {
|
|
3014
|
+
slot.statusDeferred = true;
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
if (slot.statusInFlight) {
|
|
3018
|
+
slot.statusPending = true;
|
|
3019
|
+
return;
|
|
3020
|
+
}
|
|
3021
|
+
if (slot.client.state !== 'online')
|
|
3022
|
+
return;
|
|
3023
|
+
slot.statusInFlight = true;
|
|
3024
|
+
// The race guard arms here (see the single-server stream): only frames
|
|
3025
|
+
// arriving after this point outrank the snapshot — older records have a
|
|
3026
|
+
// lower seq than startSeq and never block.
|
|
3027
|
+
const startSeq = ++statusSeq;
|
|
3028
|
+
void slot.client.invoke('session', 'list', { _request: {} }, undefined).then((value) => {
|
|
3029
|
+
slot.statusInFlight = false;
|
|
3030
|
+
if (!alive)
|
|
3031
|
+
return;
|
|
3032
|
+
const rows = isPlainObject(value) && Array.isArray(value.items) ? value.items : [];
|
|
3033
|
+
for (const row of rows) {
|
|
3034
|
+
if (!isPlainObject(row) || typeof row.sessionId !== 'string' || row.sessionId === '')
|
|
3035
|
+
continue;
|
|
3036
|
+
if ((liveStatusSeq.get(row.sessionId) ?? 0) > startSeq)
|
|
3037
|
+
continue;
|
|
3038
|
+
channel.push({
|
|
3039
|
+
type: 'emit',
|
|
3040
|
+
event: 'api-session/status',
|
|
3041
|
+
args: [toVirtual(slot.serverId, row.sessionId), row.running === true],
|
|
3042
|
+
});
|
|
3043
|
+
}
|
|
3044
|
+
if (slot.statusPending) {
|
|
3045
|
+
slot.statusPending = false;
|
|
3046
|
+
runStatusSync(slot);
|
|
3047
|
+
}
|
|
3048
|
+
}, (error) => {
|
|
3049
|
+
slot.statusInFlight = false;
|
|
3050
|
+
if (!alive)
|
|
3051
|
+
return;
|
|
3052
|
+
recordFailure('session/list', error instanceof RelayError ? error.code : 'internal');
|
|
3053
|
+
if (slot.statusPending) {
|
|
3054
|
+
slot.statusPending = false;
|
|
3055
|
+
runStatusSync(slot);
|
|
3056
|
+
}
|
|
3057
|
+
});
|
|
3058
|
+
};
|
|
3059
|
+
/** One slot's fresh serving period: the ask bookkeeping gets another
|
|
3060
|
+
* chance, and this server's rows are re-synced (statuses first). */
|
|
3061
|
+
const noteFirstOnline = (slot) => {
|
|
3062
|
+
askedForGood.clear();
|
|
3063
|
+
askAttempts.clear();
|
|
3064
|
+
retryAt.clear();
|
|
3065
|
+
latestLiveStatus.clear();
|
|
3066
|
+
if (retryTimer !== undefined) {
|
|
3067
|
+
clock.clearTimeout(retryTimer);
|
|
3068
|
+
retryTimer = undefined;
|
|
3069
|
+
}
|
|
3070
|
+
runStatusSync(slot);
|
|
3071
|
+
runSummarySync();
|
|
3072
|
+
};
|
|
3073
|
+
const pumpSlot = async (slot) => {
|
|
3074
|
+
try {
|
|
3075
|
+
while (alive && !slot.removed) {
|
|
3076
|
+
if (slot.client.state !== 'online') {
|
|
3077
|
+
await waitAny();
|
|
3078
|
+
continue;
|
|
3079
|
+
}
|
|
3080
|
+
const controller = new AbortController();
|
|
3081
|
+
slot.controller = controller;
|
|
3082
|
+
try {
|
|
3083
|
+
const stream = slot.client.openStream('$zr', 'events', {}, controller.signal);
|
|
3084
|
+
let served = false;
|
|
3085
|
+
for await (const frame of stream) {
|
|
3086
|
+
if (!alive || slot.removed || controller.signal.aborted)
|
|
3087
|
+
break;
|
|
3088
|
+
if (!served) {
|
|
3089
|
+
served = true;
|
|
3090
|
+
slot.reopenDelayMs = REMOTE_REOPEN_FIRST_MS;
|
|
3091
|
+
}
|
|
3092
|
+
const rewritten = rewriteRemoteEventFrame(frame, slot.serverId);
|
|
3093
|
+
if (rewritten === null)
|
|
3094
|
+
continue;
|
|
3095
|
+
// T75 race guard + PB-26a live status, exactly as the single
|
|
3096
|
+
// server stream keeps them — keyed by ids that are already
|
|
3097
|
+
// globally unique (virtual or original-then-virtualized).
|
|
3098
|
+
if (isPlainObject(frame) && frame.type === 'emit' && frame.event === 'api-session/status') {
|
|
3099
|
+
const rowId = Array.isArray(frame.args) ? frame.args[0] : undefined;
|
|
3100
|
+
if (typeof rowId === 'string' && rowId !== '')
|
|
3101
|
+
liveStatusSeq.set(rowId, ++statusSeq);
|
|
3102
|
+
const args = isPlainObject(rewritten) && Array.isArray(rewritten.args) ? rewritten.args : undefined;
|
|
3103
|
+
const virtualId = args?.[0];
|
|
3104
|
+
const running = args?.[1];
|
|
3105
|
+
if (typeof virtualId === 'string' && typeof running === 'boolean') {
|
|
3106
|
+
latestLiveStatus.set(virtualId, running);
|
|
3107
|
+
if (!announced.has(virtualId))
|
|
3108
|
+
handleForwardedBatch([virtualId]);
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
const eventId = isPlainObject(rewritten) && typeof rewritten.eventId === 'string' ? rewritten.eventId : undefined;
|
|
3112
|
+
if (eventId !== undefined) {
|
|
3113
|
+
if (isPlainObject(frame) && frame.type === 'waterfall') {
|
|
3114
|
+
if (slot.shownEvents.has(eventId))
|
|
3115
|
+
continue;
|
|
3116
|
+
slot.shownEvents.add(eventId);
|
|
3117
|
+
}
|
|
3118
|
+
else if (isPlainObject(frame) && frame.type === 'cancel') {
|
|
3119
|
+
slot.shownEvents.delete(eventId);
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
channel.push(rewritten);
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
catch (error) {
|
|
3126
|
+
if (alive && !slot.removed && !controller.signal.aborted && signal?.aborted !== true) {
|
|
3127
|
+
recordFailure('$zr/events', error instanceof RelayError ? error.code : 'internal');
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
finally {
|
|
3131
|
+
if (slot.controller === controller)
|
|
3132
|
+
slot.controller = undefined;
|
|
3133
|
+
}
|
|
3134
|
+
// THIS leg is over — close only ITS prompts. A disconnect is not a
|
|
3135
|
+
// verdict; the reopen re-delivers still-pending events under the
|
|
3136
|
+
// SAME ids (the server prefixes them per subscription, so the
|
|
3137
|
+
// virtual ids differ — DSH shows them fresh, the old cancels already
|
|
3138
|
+
// closed the stale prompts).
|
|
3139
|
+
for (const eventId of slot.shownEvents)
|
|
3140
|
+
channel.push({ type: 'cancel', eventId });
|
|
3141
|
+
slot.shownEvents = new Set();
|
|
3142
|
+
if (!alive || slot.removed)
|
|
3143
|
+
return;
|
|
3144
|
+
if (slot.client.state === 'online') {
|
|
3145
|
+
await waitReopen(clock, waitAny, slot.reopenDelayMs);
|
|
3146
|
+
slot.reopenDelayMs = Math.min(slot.reopenDelayMs * 2, REMOTE_REOPEN_MAX_MS);
|
|
3147
|
+
continue;
|
|
3148
|
+
}
|
|
3149
|
+
await waitAny();
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
catch (error) {
|
|
3153
|
+
if (alive)
|
|
3154
|
+
log?.('client events remote leg of %s stopped: %s', slot.serverId, messageOf(error));
|
|
3155
|
+
}
|
|
3156
|
+
};
|
|
3157
|
+
const scanSlots = () => {
|
|
3158
|
+
const seen = new Set();
|
|
3159
|
+
for (const entry of pool.entries()) {
|
|
3160
|
+
const serverId = entry.serverId;
|
|
3161
|
+
if (serverId === undefined)
|
|
3162
|
+
continue;
|
|
3163
|
+
seen.add(serverId);
|
|
3164
|
+
const existing = slots.get(serverId);
|
|
3165
|
+
if (existing !== undefined) {
|
|
3166
|
+
if (existing.displayName !== entry.displayName) {
|
|
3167
|
+
existing.displayName = entry.displayName;
|
|
3168
|
+
noteSlotIdentity(existing);
|
|
3169
|
+
}
|
|
3170
|
+
continue;
|
|
3171
|
+
}
|
|
3172
|
+
const slot = {
|
|
3173
|
+
serverId,
|
|
3174
|
+
client: entry.client,
|
|
3175
|
+
controller: undefined,
|
|
3176
|
+
offState: () => { },
|
|
3177
|
+
shownEvents: new Set(),
|
|
3178
|
+
reopenDelayMs: REMOTE_REOPEN_FIRST_MS,
|
|
3179
|
+
removed: false,
|
|
3180
|
+
displayName: entry.displayName,
|
|
3181
|
+
notedIdentity: undefined,
|
|
3182
|
+
stateSeen: undefined,
|
|
3183
|
+
statusInFlight: false,
|
|
3184
|
+
statusPending: false,
|
|
3185
|
+
statusDeferred: false,
|
|
3186
|
+
};
|
|
3187
|
+
slot.offState = slot.client.subscribe((state) => {
|
|
3188
|
+
try {
|
|
3189
|
+
const firstOnline = state === 'online' && slot.stateSeen !== 'online';
|
|
3190
|
+
slot.stateSeen = state;
|
|
3191
|
+
if (state === 'unpaired' || state === 'revoked') {
|
|
3192
|
+
slot.controller?.abort();
|
|
3193
|
+
noteSlotIdentity(slot);
|
|
3194
|
+
return;
|
|
3195
|
+
}
|
|
3196
|
+
noteSlotIdentity(slot);
|
|
3197
|
+
if (state === 'online' && firstOnline)
|
|
3198
|
+
noteFirstOnline(slot);
|
|
3199
|
+
}
|
|
3200
|
+
catch (error) {
|
|
3201
|
+
log?.('client events merge slot state handler failed on %s: %s', serverId, messageOf(error));
|
|
3202
|
+
}
|
|
3203
|
+
finally {
|
|
3204
|
+
flushWaiters();
|
|
3205
|
+
}
|
|
3206
|
+
});
|
|
3207
|
+
slots.set(serverId, slot);
|
|
3208
|
+
void pumpSlot(slot);
|
|
3209
|
+
// Install into an already-serving server: subscribe replayed nothing.
|
|
3210
|
+
if (slot.client.state === 'online') {
|
|
3211
|
+
slot.stateSeen = 'online';
|
|
3212
|
+
noteSlotIdentity(slot);
|
|
3213
|
+
noteFirstOnline(slot);
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
for (const [serverId, slot] of [...slots]) {
|
|
3217
|
+
if (!seen.has(serverId)) {
|
|
3218
|
+
slots.delete(serverId);
|
|
3219
|
+
slot.removed = true;
|
|
3220
|
+
slot.offState();
|
|
3221
|
+
slot.controller?.abort();
|
|
3222
|
+
// Only THIS server's pending prompts get the synthesized cancels.
|
|
3223
|
+
for (const eventId of slot.shownEvents)
|
|
3224
|
+
channel.push({ type: 'cancel', eventId });
|
|
3225
|
+
slot.shownEvents.clear();
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
};
|
|
3229
|
+
const offSyncRequest = deps.summarySync.onForwardedSessionIds(handleForwardedBatch);
|
|
3230
|
+
const offPool = deps.watchPool(() => {
|
|
3231
|
+
try {
|
|
3232
|
+
scanSlots();
|
|
3233
|
+
}
|
|
3234
|
+
catch (error) {
|
|
3235
|
+
log?.('client events merge pool rescan failed: %s', messageOf(error));
|
|
3236
|
+
}
|
|
3237
|
+
finally {
|
|
3238
|
+
flushWaiters();
|
|
3239
|
+
}
|
|
3240
|
+
});
|
|
3241
|
+
const onExternalAbort = () => {
|
|
3242
|
+
alive = false;
|
|
3243
|
+
for (const slot of [...slots.values()])
|
|
3244
|
+
slot.controller?.abort();
|
|
3245
|
+
flushWaiters();
|
|
3246
|
+
channel.end();
|
|
3247
|
+
};
|
|
3248
|
+
if (signal !== undefined)
|
|
3249
|
+
signal.addEventListener('abort', onExternalAbort);
|
|
3250
|
+
scanSlots();
|
|
3251
|
+
const localIterator = local[Symbol.asyncIterator]();
|
|
3252
|
+
const pumpLocal = async () => {
|
|
3253
|
+
try {
|
|
3254
|
+
while (alive) {
|
|
3255
|
+
const result = await localIterator.next();
|
|
3256
|
+
if (result.done === true)
|
|
3257
|
+
break;
|
|
3258
|
+
channel.push(result.value);
|
|
3259
|
+
if (!localOpened) {
|
|
3260
|
+
localOpened = true;
|
|
3261
|
+
if (deferredRefresh) {
|
|
3262
|
+
deferredRefresh = false;
|
|
3263
|
+
channel.push(catalogRefreshFrame());
|
|
3264
|
+
}
|
|
3265
|
+
const pendingBatch = deps.summarySync.consumePendingRequest();
|
|
3266
|
+
if (deferredSummarySync || pendingBatch.length > 0) {
|
|
3267
|
+
deferredSummarySync = false;
|
|
3268
|
+
runSummarySync(pendingBatch);
|
|
3269
|
+
}
|
|
3270
|
+
for (const slot of [...slots.values()]) {
|
|
3271
|
+
if (slot.statusDeferred) {
|
|
3272
|
+
slot.statusDeferred = false;
|
|
3273
|
+
runStatusSync(slot);
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
catch (error) {
|
|
3280
|
+
if (alive)
|
|
3281
|
+
channel.fail(error);
|
|
3282
|
+
return;
|
|
3283
|
+
}
|
|
3284
|
+
if (!alive)
|
|
3285
|
+
return;
|
|
3286
|
+
alive = false;
|
|
3287
|
+
for (const slot of [...slots.values()])
|
|
3288
|
+
slot.controller?.abort();
|
|
3289
|
+
flushWaiters();
|
|
3290
|
+
channel.end();
|
|
3291
|
+
};
|
|
3292
|
+
void pumpLocal();
|
|
3293
|
+
try {
|
|
3294
|
+
while (true) {
|
|
3295
|
+
const result = await channel.next();
|
|
3296
|
+
if (result.done === true)
|
|
3297
|
+
break;
|
|
3298
|
+
yield result.value;
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
finally {
|
|
3302
|
+
alive = false;
|
|
3303
|
+
if (retryTimer !== undefined)
|
|
3304
|
+
clock.clearTimeout(retryTimer);
|
|
3305
|
+
offSyncRequest();
|
|
3306
|
+
offPool();
|
|
3307
|
+
if (signal !== undefined)
|
|
3308
|
+
signal.removeEventListener('abort', onExternalAbort);
|
|
3309
|
+
for (const slot of [...slots.values()]) {
|
|
3310
|
+
slot.removed = true;
|
|
3311
|
+
slot.offState();
|
|
3312
|
+
slot.controller?.abort();
|
|
3313
|
+
}
|
|
3314
|
+
slots.clear();
|
|
3315
|
+
flushWaiters();
|
|
3316
|
+
void Promise.resolve(localIterator.return?.(undefined)).catch(() => { });
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
/**
|
|
3320
|
+
* Install the two own-property wrappers on the raw gateway. Assumes
|
|
3321
|
+
* {@link checkGatewayShape} passed (the wiring gates on it) — this function
|
|
3322
|
+
* records the verdict but does not re-gate, so it stays usable in tests
|
|
3323
|
+
* that install over deliberately odd shapes.
|
|
3324
|
+
*/
|
|
3325
|
+
/**
|
|
3326
|
+
* P55-2: whether `filePath` lives at or under `cwd` — the /api/file
|
|
3327
|
+
* attribution test. Both paths are compared backslash-folded to `/` (a
|
|
3328
|
+
* server-Windows cwd meets a server-Windows image path, and the Markdown
|
|
3329
|
+
* renderer may hand either separator over), trailing separators of `cwd`
|
|
3330
|
+
* are dropped, and the match is exact-or-subpath (`cwd + '/'` prefix — a
|
|
3331
|
+
* directory merely PREFIXED by the cwd string is not a match). Windows
|
|
3332
|
+
* drive-rooted pairs compare case-insensitively WHOLE (`C:\Users` vs
|
|
3333
|
+
* `c:\users` — the drive letter is the visible case hazard, but NTFS is
|
|
3334
|
+
* case-insensitive end to end, and a missed match costs a permanently
|
|
3335
|
+
* broken image while an over-match needs two same-server cwds differing
|
|
3336
|
+
* only by case); everything else stays case-sensitive (POSIX is).
|
|
3337
|
+
* Deliberately NOT node:path — the two ends may run on different platforms,
|
|
3338
|
+
* so the rule is pure string arithmetic over the server's paths.
|
|
3339
|
+
*/
|
|
3340
|
+
export function pathMatchesCwd(filePath, cwd) {
|
|
3341
|
+
const slashify = (value) => value.replaceAll('\\', '/');
|
|
3342
|
+
const file = slashify(filePath);
|
|
3343
|
+
let root = slashify(cwd);
|
|
3344
|
+
while (root.length > 1 && root.endsWith('/'))
|
|
3345
|
+
root = root.slice(0, -1);
|
|
3346
|
+
if (root === '')
|
|
3347
|
+
return false;
|
|
3348
|
+
const matches = (f, r) => f === r || f.startsWith(`${r}/`);
|
|
3349
|
+
if (matches(file, root))
|
|
3350
|
+
return true;
|
|
3351
|
+
if (/^[a-z]:/i.test(root) && /^[a-z]:/i.test(file))
|
|
3352
|
+
return matches(file.toLowerCase(), root.toLowerCase());
|
|
3353
|
+
return false;
|
|
3354
|
+
}
|
|
3355
|
+
export function installIntercept(options) {
|
|
3356
|
+
const { raw, log } = options;
|
|
3357
|
+
const clock = options.clock ?? defaultClock;
|
|
3358
|
+
// MS-3: exactly one mode — the pool when given, the single row client
|
|
3359
|
+
// otherwise. Installing with neither is a wiring bug; refuse loudly at
|
|
3360
|
+
// install instead of crashing on the first remote call.
|
|
3361
|
+
const pool = options.pool;
|
|
3362
|
+
const relay = options.relay;
|
|
3363
|
+
const getServerId = options.getServerId;
|
|
3364
|
+
if (pool === undefined && (relay === undefined || getServerId === undefined)) {
|
|
3365
|
+
throw new Error('installIntercept requires either `relay` + `getServerId` (single server) or `pool`');
|
|
3366
|
+
}
|
|
3367
|
+
// The single-server facts, proven present by the check above. The
|
|
3368
|
+
// single-server code paths read THESE (never the optional originals), so
|
|
3369
|
+
// the old behavior stays byte-identical without a narrowing dance.
|
|
3370
|
+
const singleRelay = relay;
|
|
3371
|
+
const singleGetServerId = getServerId;
|
|
3372
|
+
// T63: the summary-sync hub shared by the merged streams — the workspace
|
|
3373
|
+
// route nudges, the events leg fetches-and-announces. One per install: it
|
|
3374
|
+
// outlives individual stream generations so a nudge from a workspace leg
|
|
3375
|
+
// reaches whichever $events generation is open.
|
|
3376
|
+
const summarySync = createSessionSummarySync();
|
|
3377
|
+
// PB-25b: the inventory slot factory (the wiring's union feed) — passed
|
|
3378
|
+
// down to every merged stream, which owns one slot for its lifetime.
|
|
3379
|
+
const createSessionInventorySlot = options.createSessionInventorySlot;
|
|
3380
|
+
// MS-3: the pool-event hub behind handle.notePoolEvent() — the wiring
|
|
3381
|
+
// forwards the pool's onStateChange/onServerRemoved callbacks here, and
|
|
3382
|
+
// every live merged stream (plus the pool-mode catalog watcher below)
|
|
3383
|
+
// re-scans on the broadcast.
|
|
3384
|
+
const poolListeners = new Set();
|
|
3385
|
+
const watchPool = (fn) => {
|
|
3386
|
+
poolListeners.add(fn);
|
|
3387
|
+
return () => {
|
|
3388
|
+
poolListeners.delete(fn);
|
|
3389
|
+
};
|
|
3390
|
+
};
|
|
3391
|
+
// T62: the current-session getter and the live-merged-stream listener
|
|
3392
|
+
// registry behind handle.currentSessionChanged().
|
|
3393
|
+
const getCurrentSession = options.getCurrentSession ?? (() => ({ kind: 'unavailable' }));
|
|
3394
|
+
const currentSessionListeners = new Set();
|
|
3395
|
+
const watchCurrentSession = (fn) => {
|
|
3396
|
+
currentSessionListeners.add(fn);
|
|
3397
|
+
return () => { currentSessionListeners.delete(fn); };
|
|
3398
|
+
};
|
|
3399
|
+
const uninstallListeners = new Set();
|
|
3400
|
+
const watchUninstall = (fn) => {
|
|
3401
|
+
uninstallListeners.add(fn);
|
|
3402
|
+
return () => { uninstallListeners.delete(fn); };
|
|
3403
|
+
};
|
|
3404
|
+
const gateway = raw;
|
|
3405
|
+
const shape = checkGatewayShape(raw);
|
|
3406
|
+
const counters = { openWireStream: 0, dispatchRpc: 0 };
|
|
3407
|
+
const failures = [];
|
|
3408
|
+
const incompatible = [];
|
|
3409
|
+
// The closed-session registry (T34): insertion-ordered, capped, virtual-id
|
|
3410
|
+
// keyed. Entries live until THEIR session proves reachable again (a
|
|
3411
|
+
// succeeding call for it) — a reconnect clears nothing (T34-fix): a link
|
|
3412
|
+
// that flapped under a still-standing closure would make the banner flicker
|
|
3413
|
+
// away and back.
|
|
3414
|
+
const closedSessions = new Map();
|
|
3415
|
+
// T73-fix: waiters for "a closed-session record was just cleared" — the
|
|
3416
|
+
// quiet session/follow hold (rewriteUpstream) ends its silence the moment
|
|
3417
|
+
// the session it holds for is served again, so a re-enabled remote comes
|
|
3418
|
+
// back with a fresh history instead of staying blind under its banner.
|
|
3419
|
+
const closedClearWaiters = new Set();
|
|
3420
|
+
const remoteSessionCwds = new Map();
|
|
3421
|
+
const activeRemoteFollows = new Map();
|
|
3422
|
+
let lastFollowedSession;
|
|
3423
|
+
const MAX_REMOTE_SESSION_CWDS = 200;
|
|
3424
|
+
const recordRemoteSessionCwd = (serverId, rawSessionId, cwd) => {
|
|
3425
|
+
if (typeof rawSessionId !== 'string' || rawSessionId === '')
|
|
3426
|
+
return;
|
|
3427
|
+
if (typeof cwd !== 'string' || cwd === '')
|
|
3428
|
+
return;
|
|
3429
|
+
const key = toVirtual(serverId, rawSessionId);
|
|
3430
|
+
// Re-insert to refresh the recency order, then cap like `closedSessions`.
|
|
3431
|
+
remoteSessionCwds.delete(key);
|
|
3432
|
+
if (remoteSessionCwds.size >= MAX_REMOTE_SESSION_CWDS) {
|
|
3433
|
+
const oldest = remoteSessionCwds.keys().next();
|
|
3434
|
+
if (oldest.done !== true)
|
|
3435
|
+
remoteSessionCwds.delete(oldest.value);
|
|
3436
|
+
}
|
|
3437
|
+
remoteSessionCwds.set(key, { serverId, sessionId: rawSessionId, cwd });
|
|
3438
|
+
};
|
|
3439
|
+
/** Record every cwd a remote `session/list` result carries (both merge
|
|
3440
|
+
* modes call this on the RAW remote value, before ids go virtual). */
|
|
3441
|
+
const noteRemoteSessionListCwds = (remoteResult, serverId) => {
|
|
3442
|
+
if (!isPlainObject(remoteResult) || !Array.isArray(remoteResult.items))
|
|
3443
|
+
return;
|
|
3444
|
+
for (const item of remoteResult.items) {
|
|
3445
|
+
if (!isPlainObject(item))
|
|
3446
|
+
continue;
|
|
3447
|
+
const id = typeof item.sessionId === 'string' && item.sessionId !== ''
|
|
3448
|
+
? item.sessionId
|
|
3449
|
+
: typeof item.id === 'string' && item.id !== ''
|
|
3450
|
+
? item.id
|
|
3451
|
+
: undefined;
|
|
3452
|
+
if (id === undefined)
|
|
3453
|
+
continue;
|
|
3454
|
+
recordRemoteSessionCwd(serverId, id, item.cwd);
|
|
3455
|
+
}
|
|
3456
|
+
};
|
|
3457
|
+
/**
|
|
3458
|
+
* The resolution the /api/file wrap buys with one call. Steps, in order:
|
|
3459
|
+
* A) the ACTIVE remote session — the browser-reported current session
|
|
3460
|
+
* (unless this process's newest follow went LOCAL, which is the newer
|
|
3461
|
+
* fact), else the newest follow, else the newest live remote follow;
|
|
3462
|
+
* B) a precise cwd match — the active session's own cwd first, then any
|
|
3463
|
+
* known cwd the path falls under (a session with a LIVE follow preferred,
|
|
3464
|
+
* else the most recently refreshed match);
|
|
3465
|
+
* C) no cwd claim anywhere → the active remote (so a just-opened page's
|
|
3466
|
+
* first image resolves with zero cwd knowledge), or `undefined` when no
|
|
3467
|
+
* remote session is active — a local session's images stay local.
|
|
3468
|
+
*/
|
|
3469
|
+
const resolveRemoteFileScope = (rawPath) => {
|
|
3470
|
+
if (typeof rawPath !== 'string' || rawPath === '')
|
|
3471
|
+
return undefined;
|
|
3472
|
+
let activeRemote;
|
|
3473
|
+
const current = getCurrentSession();
|
|
3474
|
+
if (current.kind === 'open') {
|
|
3475
|
+
const parts = fromVirtual(current.sessionId);
|
|
3476
|
+
if (parts !== undefined && lastFollowedSession?.kind !== 'local') {
|
|
3477
|
+
activeRemote = { serverId: parts.serverId, sessionId: parts.id };
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
if (activeRemote === undefined && lastFollowedSession?.kind === 'remote') {
|
|
3481
|
+
activeRemote = { serverId: lastFollowedSession.serverId, sessionId: lastFollowedSession.sessionId };
|
|
3482
|
+
}
|
|
3483
|
+
if (activeRemote === undefined && activeRemoteFollows.size > 0) {
|
|
3484
|
+
const values = [...activeRemoteFollows.values()];
|
|
3485
|
+
const last = values[values.length - 1];
|
|
3486
|
+
if (last !== undefined)
|
|
3487
|
+
activeRemote = { ...last };
|
|
3488
|
+
}
|
|
3489
|
+
if (activeRemote !== undefined) {
|
|
3490
|
+
const own = remoteSessionCwds.get(toVirtual(activeRemote.serverId, activeRemote.sessionId));
|
|
3491
|
+
if (own !== undefined && pathMatchesCwd(rawPath, own.cwd)) {
|
|
3492
|
+
return { serverId: own.serverId, sessionId: own.sessionId };
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
const followedVirtuals = new Set([...activeRemoteFollows.values()].map((entry) => toVirtual(entry.serverId, entry.sessionId)));
|
|
3496
|
+
let latestMatch;
|
|
3497
|
+
let followedMatch;
|
|
3498
|
+
for (const entry of remoteSessionCwds.values()) {
|
|
3499
|
+
if (!pathMatchesCwd(rawPath, entry.cwd))
|
|
3500
|
+
continue;
|
|
3501
|
+
latestMatch = entry;
|
|
3502
|
+
if (followedVirtuals.has(toVirtual(entry.serverId, entry.sessionId)))
|
|
3503
|
+
followedMatch = entry;
|
|
3504
|
+
}
|
|
3505
|
+
if (followedMatch !== undefined)
|
|
3506
|
+
return { serverId: followedMatch.serverId, sessionId: followedMatch.sessionId };
|
|
3507
|
+
if (latestMatch !== undefined)
|
|
3508
|
+
return { serverId: latestMatch.serverId, sessionId: latestMatch.sessionId };
|
|
3509
|
+
return activeRemote;
|
|
3510
|
+
};
|
|
3511
|
+
// The last successfully fetched SERVER model catalog (T52-fix), kept for
|
|
3512
|
+
// the offline merges of the `session/modelCatalog` route below — cleared
|
|
3513
|
+
// on a pairing wall (eagerly, by the T52-fix2 catalog watcher; the route's
|
|
3514
|
+
// own drop is the backstop) and never served for another server's id (see the
|
|
3515
|
+
// route's doc). Only an in-memory value: the groups are display data and
|
|
3516
|
+
// a reload refetches them anyway. Since T52-fix3 it feeds ONLY this route:
|
|
3517
|
+
// the modelSelection provider rewrites are unconditional again (the host's
|
|
3518
|
+
// projection store is first-write-wins per seq, so a catalog-gated rewrite
|
|
3519
|
+
// would poison the store — see virtualizeModelSelectionValue), and the
|
|
3520
|
+
// `groups` membership set is gone with the gate.
|
|
3521
|
+
let serverCatalogCache;
|
|
3522
|
+
/**
|
|
3523
|
+
* The serving generation, bumped on every pairing wall (`unpaired` /
|
|
3524
|
+
* `revoked`, T52-fix3): a catalog fetch that was IN FLIGHT when the wall
|
|
3525
|
+
* rose must not write its answer back — the wall's groups must not outlive
|
|
3526
|
+
* it, and a late write would resurrect them under the offline merge below.
|
|
3527
|
+
* Both cache writes (the proactive fetch here, the route's own fetch)
|
|
3528
|
+
* snapshot the counter before invoking and compare before storing; a
|
|
3529
|
+
* mismatched answer is silently dropped — it is not a fault, the next
|
|
3530
|
+
* serving period fetches fresh anyway.
|
|
3531
|
+
*/
|
|
3532
|
+
let catalogGeneration = 0;
|
|
3533
|
+
const rememberServerCatalog = (identity, value, generation) => {
|
|
3534
|
+
if (generation !== catalogGeneration)
|
|
3535
|
+
return;
|
|
3536
|
+
serverCatalogCache = { identity, value };
|
|
3537
|
+
};
|
|
3538
|
+
/** MS-3 pool mode: the per-server form of the same cache — one entry per
|
|
3539
|
+
* known `serverId`, keyed by it. An offline entry keeps serving its cache
|
|
3540
|
+
* (the groups must not go blank mid-outage); an entry that LEAVES the pool
|
|
3541
|
+
* takes its cache with it (its groups must not outlive the pairing). */
|
|
3542
|
+
const serverCatalogCaches = new Map();
|
|
3543
|
+
const catalogGenerations = new Map();
|
|
3544
|
+
/** One server-catalog fetch whose answer only feeds the cache (T52-fix2):
|
|
3545
|
+
* the proactive pull that runs as soon as the relay serves — and once at
|
|
3546
|
+
* install when the relay already serves (subscribe replays no state —
|
|
3547
|
+
* relay-client.ts setState returns on a same-state write — so the
|
|
3548
|
+
* subscription alone would miss an install-into-online). Failures are
|
|
3549
|
+
* diagnostics-ring entries, nothing else (the route's own fetch is the
|
|
3550
|
+
* user-visible path and answers local-only there). */
|
|
3551
|
+
const fetchServerCatalog = (identity) => {
|
|
3552
|
+
const generation = catalogGeneration;
|
|
3553
|
+
void singleRelay.invoke('session', 'modelCatalog', {}, undefined).then((value) => {
|
|
3554
|
+
rememberServerCatalog(identity, value, generation);
|
|
3555
|
+
}, (error) => {
|
|
3556
|
+
recordFailure('session/modelCatalog', error instanceof RelayError ? error.code : 'internal');
|
|
3557
|
+
});
|
|
3558
|
+
};
|
|
3559
|
+
/** MS-3 pool mode: the per-server proactive fetch. The identity's display
|
|
3560
|
+
* name is the POOL's (the `-1`/`-2` disambiguated one) — it titles the
|
|
3561
|
+
* merged catalog's virtual groups. Generation-guarded per server, like
|
|
3562
|
+
* the single-server fetch above. */
|
|
3563
|
+
const fetchServerCatalogFor = (entry) => {
|
|
3564
|
+
const serverId = entry.serverId;
|
|
3565
|
+
const generation = catalogGenerations.get(serverId) ?? 0;
|
|
3566
|
+
void entry.client.invoke('session', 'modelCatalog', {}, undefined).then((value) => {
|
|
3567
|
+
if ((catalogGenerations.get(serverId) ?? 0) !== generation)
|
|
3568
|
+
return;
|
|
3569
|
+
serverCatalogCaches.set(serverId, { identity: { serverId, serverName: entry.displayName }, value });
|
|
3570
|
+
}, (error) => {
|
|
3571
|
+
recordFailure('session/modelCatalog', error instanceof RelayError ? error.code : 'internal');
|
|
3572
|
+
});
|
|
3573
|
+
};
|
|
3574
|
+
// The catalog watcher (T52-fix2). The subscription fires on the real
|
|
3575
|
+
// online transitions (first handshake, every recovery) — and since T59
|
|
3576
|
+
// also on name-only notifications, which arrive with the state still
|
|
3577
|
+
// `online` and are deduped by the same refreshKey/stateSeen gates above.
|
|
3578
|
+
// The install-time check below covers an install into an already-online
|
|
3579
|
+
// relay. `unpaired` / `revoked`
|
|
3580
|
+
// drop the cache the same way the catalog route does lazily — the wall's
|
|
3581
|
+
// groups must not outlive it — and bump the generation so an in-flight
|
|
3582
|
+
// fetch cannot write the old catalog back (T52-fix3). Faults stay
|
|
3583
|
+
// contained: the sync body cannot throw, and the fetch settles through
|
|
3584
|
+
// .then/.catch above.
|
|
3585
|
+
let offCatalogWatch;
|
|
3586
|
+
// MS-3 pool mode: one client subscription per pool entry (the map below),
|
|
3587
|
+
// created and retired by the watchPool rescan. MS-5: uninstall tears them
|
|
3588
|
+
// ALL down — `offCatalogWatch` only unsubscribes the rescan itself, and a
|
|
3589
|
+
// left-behind per-entry subscription would keep firing into this dead
|
|
3590
|
+
// generation's caches on every later state change.
|
|
3591
|
+
const catalogWatched = new Map();
|
|
3592
|
+
if (pool === undefined) {
|
|
3593
|
+
try {
|
|
3594
|
+
offCatalogWatch = singleRelay.subscribe((state) => {
|
|
3595
|
+
if (state === 'unpaired' || state === 'revoked') {
|
|
3596
|
+
catalogGeneration += 1;
|
|
3597
|
+
serverCatalogCache = undefined;
|
|
3598
|
+
return;
|
|
3599
|
+
}
|
|
3600
|
+
if (state !== 'online')
|
|
3601
|
+
return;
|
|
3602
|
+
const identity = relayIdentityOf(singleRelay);
|
|
3603
|
+
if (identity !== undefined)
|
|
3604
|
+
fetchServerCatalog(identity);
|
|
3605
|
+
});
|
|
3606
|
+
// Install into an already-serving relay: subscribe replayed nothing, so
|
|
3607
|
+
// the transition-based fetch would wait for the NEXT flap — fill the
|
|
3608
|
+
// cache now instead (T52-fix3).
|
|
3609
|
+
if (singleRelay.state === 'online') {
|
|
3610
|
+
const identity = relayIdentityOf(singleRelay);
|
|
3611
|
+
if (identity !== undefined)
|
|
3612
|
+
fetchServerCatalog(identity);
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
catch (error) {
|
|
3616
|
+
// Subscription failed: the cache still fills whenever the UI opens a
|
|
3617
|
+
// dropdown (the route's own fetch), only later.
|
|
3618
|
+
log?.('client catalog watcher unavailable: %s', messageOf(error));
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
else {
|
|
3622
|
+
// MS-3 pool mode: one watcher per entry, rescanned on every pool event.
|
|
3623
|
+
// `unpaired` / `revoked` drop THAT server's cache and bump its
|
|
3624
|
+
// generation; `online` proactively refetches it (an entry appearing
|
|
3625
|
+
// already online is fetched at attach, same as the single-server
|
|
3626
|
+
// install-into-online above).
|
|
3627
|
+
offCatalogWatch = watchPool(() => {
|
|
3628
|
+
const seen = new Set();
|
|
3629
|
+
for (const entry of pool.entries()) {
|
|
3630
|
+
seen.add(entry.entryId);
|
|
3631
|
+
if (catalogWatched.has(entry.entryId))
|
|
3632
|
+
continue;
|
|
3633
|
+
const unsubscribe = entry.client.subscribe((state) => {
|
|
3634
|
+
// The entry's serverId is read LIVE — a re-handshake may have
|
|
3635
|
+
// renamed it since this listener attached.
|
|
3636
|
+
const live = pool.entries().find((candidate) => candidate.entryId === entry.entryId);
|
|
3637
|
+
const serverId = live?.serverId;
|
|
3638
|
+
if (serverId === undefined)
|
|
3639
|
+
return;
|
|
3640
|
+
if (state === 'unpaired' || state === 'revoked') {
|
|
3641
|
+
catalogGenerations.set(serverId, (catalogGenerations.get(serverId) ?? 0) + 1);
|
|
3642
|
+
serverCatalogCaches.delete(serverId);
|
|
3643
|
+
return;
|
|
3644
|
+
}
|
|
3645
|
+
if (state !== 'online')
|
|
3646
|
+
return;
|
|
3647
|
+
const current = live ?? entry;
|
|
3648
|
+
if (current.serverId !== undefined) {
|
|
3649
|
+
fetchServerCatalogFor({ serverId: current.serverId, displayName: current.displayName, client: current.client });
|
|
3650
|
+
}
|
|
3651
|
+
});
|
|
3652
|
+
catalogWatched.set(entry.entryId, unsubscribe);
|
|
3653
|
+
if (entry.client.state === 'online' && entry.serverId !== undefined) {
|
|
3654
|
+
fetchServerCatalogFor({ serverId: entry.serverId, displayName: entry.displayName, client: entry.client });
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
for (const [entryId, unsubscribe] of [...catalogWatched]) {
|
|
3658
|
+
if (!seen.has(entryId)) {
|
|
3659
|
+
unsubscribe();
|
|
3660
|
+
catalogWatched.delete(entryId);
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
});
|
|
3664
|
+
// Run the initial scan through the hub so the same rescan logic runs.
|
|
3665
|
+
for (const listener of [...poolListeners])
|
|
3666
|
+
listener();
|
|
3667
|
+
}
|
|
3668
|
+
const registerClosed = (sessionId, reason) => {
|
|
3669
|
+
closedSessions.delete(sessionId);
|
|
3670
|
+
if (closedSessions.size >= MAX_CLOSED_SESSIONS) {
|
|
3671
|
+
const oldest = closedSessions.keys().next();
|
|
3672
|
+
if (oldest.done !== true)
|
|
3673
|
+
closedSessions.delete(oldest.value);
|
|
3674
|
+
}
|
|
3675
|
+
closedSessions.set(sessionId, reason);
|
|
3676
|
+
};
|
|
3677
|
+
const clearClosed = (virtuals) => {
|
|
3678
|
+
let cleared = false;
|
|
3679
|
+
for (const parts of virtuals) {
|
|
3680
|
+
if (closedSessions.delete(toVirtual(parts.serverId, parts.id)))
|
|
3681
|
+
cleared = true;
|
|
3682
|
+
}
|
|
3683
|
+
if (cleared)
|
|
3684
|
+
for (const waiter of [...closedClearWaiters])
|
|
3685
|
+
waiter();
|
|
3686
|
+
};
|
|
3687
|
+
let installed = true;
|
|
3688
|
+
let selfCheck;
|
|
3689
|
+
/**
|
|
3690
|
+
* Reload overlay, for the record (CP4): the theoretical hazard is installing
|
|
3691
|
+
* a NEW wrapper while an OLD one is still installed, so each captures the
|
|
3692
|
+
* other and the older survives as a live layer. Cordis `fiber.restart()`
|
|
3693
|
+
* disposes the old fiber FIRST (its effect calls {@link InterceptHandle.uninstall},
|
|
3694
|
+
* restoring the saved property) and only then applies the new row — the
|
|
3695
|
+
* overlay ordering never happens today, so there is nothing to absorb at
|
|
3696
|
+
* install time. Detection is also not cheaply implementable: a reloaded row
|
|
3697
|
+
* loads a FRESH module instance, so a stale wrapper shares no mark with the
|
|
3698
|
+
* new one — telling "ours, from a dead generation" apart from another
|
|
3699
|
+
* plugin's wrapper would take a process-wide registry, which the comment
|
|
3700
|
+
* above argues is not worth it while the ordering cannot occur. The
|
|
3701
|
+
* `installed === false → inert passthrough` rule below is the safety net
|
|
3702
|
+
* for the ordering that CAN happen: a later plugin wrapping OVER us and
|
|
3703
|
+
* keeping a reference past our uninstall.
|
|
3704
|
+
*
|
|
3705
|
+
* Save the CURRENT value per property (possibly another plugin's wrapper —
|
|
3706
|
+
* shape note "own-property"), falling back to the prototype method only
|
|
3707
|
+
* when the current value is not callable. Uninstall restores the saved
|
|
3708
|
+
* own state verbatim: value if there was one, absence if not.
|
|
3709
|
+
*/
|
|
3710
|
+
const saved = {};
|
|
3711
|
+
const chainTarget = {};
|
|
3712
|
+
for (const name of ['openWireStream', 'dispatchRpc']) {
|
|
3713
|
+
const existed = Object.prototype.hasOwnProperty.call(gateway, name);
|
|
3714
|
+
const value = existed ? gateway[name] : undefined;
|
|
3715
|
+
saved[name] = { existed, value };
|
|
3716
|
+
chainTarget[name] = typeof value === 'function' ? value : Object.getPrototypeOf(raw)?.[name];
|
|
3717
|
+
}
|
|
3718
|
+
// Filled in below, once the wrapper functions exist: uninstall may only
|
|
3719
|
+
// touch a property that still holds OUR wrapper.
|
|
3720
|
+
const wrappers = {};
|
|
3721
|
+
const recordFailure = (endpoint, code) => {
|
|
3722
|
+
if (failures.length >= MAX_FAILURES)
|
|
3723
|
+
failures.shift();
|
|
3724
|
+
failures.push({ time: new Date().toISOString(), endpoint, code });
|
|
3725
|
+
// T42: validation-shaped refusals additionally land in the incompatible
|
|
3726
|
+
// ring — every remote failure funnels through this one function, so the
|
|
3727
|
+
// classification cannot drift between the invoke and stream routes.
|
|
3728
|
+
if (!INCOMPATIBLE_CALL_CODES.has(code))
|
|
3729
|
+
return;
|
|
3730
|
+
if (incompatible.length >= MAX_INCOMPATIBLE_CALLS)
|
|
3731
|
+
incompatible.shift();
|
|
3732
|
+
incompatible.push({ time: Date.now(), endpoint, code });
|
|
3733
|
+
};
|
|
3734
|
+
const failEnvelope = (endpoint, code, text) => {
|
|
3735
|
+
recordFailure(endpoint, code);
|
|
3736
|
+
// `details: {}` is load-bearing: the UI's server-response parser throws
|
|
3737
|
+
// `invalid server-response failure` on a details-less error (and the
|
|
3738
|
+
// gateway's rpcErrorSchema demands the same shape).
|
|
3739
|
+
return { ok: false, error: { code, message: text, details: {} } };
|
|
3740
|
+
};
|
|
3741
|
+
/**
|
|
3742
|
+
* The shared validation both routes apply before forwarding — now (MS-3)
|
|
3743
|
+
* resolving the TARGET SERVER as well as the call's shape. The single
|
|
3744
|
+
* `relay` mode keeps the original verdict order byte-for-byte (offline →
|
|
3745
|
+
* mismatch → unsupported); pool mode routes by the virtual id's serverId:
|
|
3746
|
+
* no handshook server in the pool answers `remote-offline`, a handshook
|
|
3747
|
+
* pool without the target server answers `remote-mismatch`, and the
|
|
3748
|
+
* matched entry's client is the forwarding leg. A refusal is recorded
|
|
3749
|
+
* once, here, by the caller passing it through {@link failEnvelope}.
|
|
3750
|
+
*/
|
|
3751
|
+
const copy = () => INTERCEPT_COPY[resolveInterceptLang(options.lang)];
|
|
3752
|
+
function validate(endpoint, virtuals) {
|
|
3753
|
+
const first = virtuals[0].serverId;
|
|
3754
|
+
const c = copy();
|
|
3755
|
+
if (pool === undefined) {
|
|
3756
|
+
const current = singleGetServerId();
|
|
3757
|
+
if (current === undefined)
|
|
3758
|
+
return { ok: false, code: 'remote-offline', message: c.notConnected };
|
|
3759
|
+
if (virtuals.some((parts) => parts.serverId !== first) || current !== first) {
|
|
3760
|
+
return { ok: false, code: 'remote-mismatch', message: c.mismatch };
|
|
3761
|
+
}
|
|
3762
|
+
if (CLIENT_METHOD_FIELDS[endpoint] === undefined) {
|
|
3763
|
+
return { ok: false, code: 'remote-unsupported', message: c.unsupported };
|
|
3764
|
+
}
|
|
3765
|
+
return { ok: true, client: singleRelay, serverId: first };
|
|
3766
|
+
}
|
|
3767
|
+
if (virtuals.some((parts) => parts.serverId !== first)) {
|
|
3768
|
+
return { ok: false, code: 'remote-mismatch', message: c.mismatch };
|
|
3769
|
+
}
|
|
3770
|
+
const known = pool.entries().filter((entry) => entry.serverId !== undefined);
|
|
3771
|
+
if (known.length === 0)
|
|
3772
|
+
return { ok: false, code: 'remote-offline', message: c.notConnected };
|
|
3773
|
+
const target = known.find((entry) => entry.serverId === first);
|
|
3774
|
+
if (target === undefined)
|
|
3775
|
+
return { ok: false, code: 'remote-mismatch', message: c.mismatch };
|
|
3776
|
+
if (CLIENT_METHOD_FIELDS[endpoint] === undefined) {
|
|
3777
|
+
return { ok: false, code: 'remote-unsupported', message: c.unsupported };
|
|
3778
|
+
}
|
|
3779
|
+
return { ok: true, client: target.client, serverId: first };
|
|
3780
|
+
}
|
|
3781
|
+
/**
|
|
3782
|
+
* The T32 `$events/result` split: an answer whose eventId is VIRTUAL
|
|
3783
|
+
* belongs to a forwarded remote event and must NOT reach the local
|
|
3784
|
+
* gateway (its own `$events` never delivered it — the local dispatcher
|
|
3785
|
+
* would answer with its `no active event stream` failure). The virtual
|
|
3786
|
+
* id goes back to the original, the local payload's clientId (the local
|
|
3787
|
+
* stream's own) is discarded — the relay server composes the gateway
|
|
3788
|
+
* payload from ITS subscription's clientId — and the outcome rides
|
|
3789
|
+
* verbatim. Anything else, including a malformed payload, is the local
|
|
3790
|
+
* gateway's business.
|
|
3791
|
+
*/
|
|
3792
|
+
function forwardEventResult(payload, signal, peer) {
|
|
3793
|
+
const args = argsOf(payload);
|
|
3794
|
+
const eventId = isPlainObject(args) ? args.eventId : undefined;
|
|
3795
|
+
const virtual = fromVirtual(eventId);
|
|
3796
|
+
if (virtual === undefined) {
|
|
3797
|
+
return chainTarget.dispatchRpc.call(raw, '$events/result', payload, signal, peer);
|
|
3798
|
+
}
|
|
3799
|
+
const endpoint = '$events/result';
|
|
3800
|
+
const route = (() => {
|
|
3801
|
+
if (pool === undefined) {
|
|
3802
|
+
const current = singleGetServerId();
|
|
3803
|
+
if (current === undefined || singleRelay.state !== 'online')
|
|
3804
|
+
return 'offline';
|
|
3805
|
+
if (virtual.serverId !== current)
|
|
3806
|
+
return 'mismatch';
|
|
3807
|
+
return { client: singleRelay };
|
|
3808
|
+
}
|
|
3809
|
+
const target = pool.getByServerId(virtual.serverId);
|
|
3810
|
+
if (target === undefined)
|
|
3811
|
+
return 'missing';
|
|
3812
|
+
if (target.client.state !== 'online')
|
|
3813
|
+
return 'offline';
|
|
3814
|
+
return { client: target.client };
|
|
3815
|
+
})();
|
|
3816
|
+
// Offline (T34-fix): a refusal envelope would RESTART the UI's whole
|
|
3817
|
+
// `$events` generation — the client face's answer() throws on a
|
|
3818
|
+
// `!response.ok` body and the pump treats that as a delivery failure
|
|
3819
|
+
// (RT dsh-api-gateway lib/client.js: `if (!response.ok) throw new
|
|
2468
3820
|
// Error(response.error.message)` feeding `failed.abort(error)`), and the
|
|
2469
3821
|
// synthesized cancels the dying leg already sent have closed these
|
|
2470
3822
|
// prompts anyway. Answer the silent ok DSH itself gives a stale result,
|
|
2471
3823
|
// and record the refusal in the diagnostics ring instead — the
|
|
2472
|
-
// non-online write-refusal message, so the banner's word matches.
|
|
2473
|
-
|
|
2474
|
-
|
|
3824
|
+
// non-online write-refusal message, so the banner's word matches. In
|
|
3825
|
+
// pool mode a leg the pool no longer serves (`missing` — unpaired away,
|
|
3826
|
+
// deduped, never paired) reads the same way: nothing can settle those
|
|
3827
|
+
// events anymore, so the silent ok wins there too. The single-server
|
|
3828
|
+
// mismatch keeps its original REFUSAL envelope (wrong server is a
|
|
3829
|
+
// different fact from a leg that just left).
|
|
3830
|
+
if (route === 'offline') {
|
|
3831
|
+
failEnvelope(endpoint, 'remote-offline', copy().writeOffline);
|
|
3832
|
+
return { ok: true, value: undefined };
|
|
3833
|
+
}
|
|
3834
|
+
if (route === 'missing') {
|
|
3835
|
+
failEnvelope(endpoint, 'remote-mismatch', copy().mismatch);
|
|
2475
3836
|
return { ok: true, value: undefined };
|
|
2476
3837
|
}
|
|
2477
|
-
if (
|
|
2478
|
-
return failEnvelope(endpoint, 'remote-mismatch',
|
|
3838
|
+
if (route === 'mismatch')
|
|
3839
|
+
return failEnvelope(endpoint, 'remote-mismatch', copy().mismatch);
|
|
3840
|
+
const client = route.client;
|
|
2479
3841
|
return (async () => {
|
|
2480
3842
|
try {
|
|
2481
|
-
await
|
|
3843
|
+
await client.postEventResult(virtual.id, isPlainObject(args) ? args.outcome : undefined, signal);
|
|
2482
3844
|
return { ok: true, value: undefined };
|
|
2483
3845
|
}
|
|
2484
3846
|
catch (error) {
|
|
@@ -2515,11 +3877,13 @@ export function installIntercept(options) {
|
|
|
2515
3877
|
// A WRITE while the relay is not serving (T34, whitelist rule per
|
|
2516
3878
|
// T34-fix): refused here, never sent — the call could only fail out
|
|
2517
3879
|
// there, and the read paths answer for themselves (forwarded as today,
|
|
2518
|
-
// failing with their own transport error).
|
|
2519
|
-
|
|
2520
|
-
|
|
3880
|
+
// failing with their own transport error). Pool mode (MS-3): the verdict
|
|
3881
|
+
// names the TARGET leg — its state is the one that counts.
|
|
3882
|
+
if (isRemoteWrite(endpoint) && verdict.client.state !== 'online') {
|
|
3883
|
+
return failEnvelope(endpoint, 'remote-offline', copy().writeOffline);
|
|
2521
3884
|
}
|
|
2522
3885
|
const serverId = verdict.serverId;
|
|
3886
|
+
const client = verdict.client;
|
|
2523
3887
|
const slash = endpoint.indexOf('/');
|
|
2524
3888
|
const namespace = slash === -1 ? endpoint : endpoint.slice(0, slash);
|
|
2525
3889
|
const method = slash === -1 ? '' : endpoint.slice(slash + 1);
|
|
@@ -2546,9 +3910,9 @@ export function installIntercept(options) {
|
|
|
2546
3910
|
const references = restoreSessionReferences(clone, namespace, method, serverId);
|
|
2547
3911
|
clone = references.args;
|
|
2548
3912
|
if (!references.ok) {
|
|
2549
|
-
return failEnvelope(endpoint, 'remote-unsupported',
|
|
3913
|
+
return failEnvelope(endpoint, 'remote-unsupported', copy().referenceNotOnServer);
|
|
2550
3914
|
}
|
|
2551
|
-
const value = await
|
|
3915
|
+
const value = await client.invoke(namespace, method, clone, signal);
|
|
2552
3916
|
// The session answered: any closed-session entry for it is stale
|
|
2553
3917
|
// (re-shared and served again) — the banner may go.
|
|
2554
3918
|
clearClosed(virtuals);
|
|
@@ -2578,7 +3942,7 @@ export function installIntercept(options) {
|
|
|
2578
3942
|
recordFailure(endpoint, code);
|
|
2579
3943
|
// A call on a closed session reads like the banner, not like the
|
|
2580
3944
|
// relay's bare code (T73).
|
|
2581
|
-
const message = code === 'not-shared' ?
|
|
3945
|
+
const message = code === 'not-shared' ? copy().closed : messageOf(error);
|
|
2582
3946
|
return { ok: false, error: { code, message, details: error instanceof RelayError && isPlainObject(error.details) ? error.details : {} } };
|
|
2583
3947
|
}
|
|
2584
3948
|
}
|
|
@@ -2601,20 +3965,20 @@ export function installIntercept(options) {
|
|
|
2601
3965
|
* can never reach us (subscribe's contract keeps listener faults contained
|
|
2602
3966
|
* on the client side).
|
|
2603
3967
|
*/
|
|
2604
|
-
function waitForOnline(signal) {
|
|
3968
|
+
function waitForOnline(client, signal) {
|
|
2605
3969
|
return new Promise((resolve) => {
|
|
2606
3970
|
if (signal?.aborted === true) {
|
|
2607
3971
|
resolve('aborted');
|
|
2608
3972
|
return;
|
|
2609
3973
|
}
|
|
2610
|
-
if (
|
|
3974
|
+
if (client.state === 'online') {
|
|
2611
3975
|
resolve('online');
|
|
2612
3976
|
return;
|
|
2613
3977
|
}
|
|
2614
3978
|
// The wall may already be down when the wait starts (the leg died and
|
|
2615
3979
|
// the state flipped before this call) — same verdict as below.
|
|
2616
|
-
if (
|
|
2617
|
-
resolve(
|
|
3980
|
+
if (client.state === 'revoked' || client.state === 'unpaired') {
|
|
3981
|
+
resolve(client.state);
|
|
2618
3982
|
return;
|
|
2619
3983
|
}
|
|
2620
3984
|
let off;
|
|
@@ -2627,7 +3991,7 @@ export function installIntercept(options) {
|
|
|
2627
3991
|
resolve(verdict);
|
|
2628
3992
|
};
|
|
2629
3993
|
const onAbort = () => settle('aborted');
|
|
2630
|
-
off =
|
|
3994
|
+
off = client.subscribe((state) => {
|
|
2631
3995
|
if (state === 'online')
|
|
2632
3996
|
settle('online');
|
|
2633
3997
|
else if (state === 'revoked' || state === 'unpaired')
|
|
@@ -2660,16 +4024,16 @@ export function installIntercept(options) {
|
|
|
2660
4024
|
* current backoff step. `aborted` only on caller abort; `revoked` /
|
|
2661
4025
|
* `unpaired` when the pairing wall came down (CP5).
|
|
2662
4026
|
*/
|
|
2663
|
-
async function waitServeAgain(signal, backoffMs) {
|
|
2664
|
-
if (
|
|
4027
|
+
async function waitServeAgain(client, signal, backoffMs) {
|
|
4028
|
+
if (client.state === 'online') {
|
|
2665
4029
|
await delayWait(signal, backoffMs);
|
|
2666
4030
|
if (signal?.aborted === true)
|
|
2667
4031
|
return 'aborted';
|
|
2668
|
-
if (
|
|
2669
|
-
return waitForOnline(signal);
|
|
4032
|
+
if (client.state !== 'online')
|
|
4033
|
+
return waitForOnline(client, signal);
|
|
2670
4034
|
return 'online';
|
|
2671
4035
|
}
|
|
2672
|
-
return waitForOnline(signal);
|
|
4036
|
+
return waitForOnline(client, signal);
|
|
2673
4037
|
}
|
|
2674
4038
|
/**
|
|
2675
4039
|
* Pump one session-level remote stream into the UI (CP4, SPEC story 54
|
|
@@ -2711,7 +4075,7 @@ export function installIntercept(options) {
|
|
|
2711
4075
|
* history stream after its first frame it ends nothing at all — the
|
|
2712
4076
|
* stream goes silent until the page closes it (T73, at the catch below).
|
|
2713
4077
|
*/
|
|
2714
|
-
async function* rewriteUpstream(endpoint, upstream, serverId, claimed, signal, reopen) {
|
|
4078
|
+
async function* rewriteUpstream(endpoint, client, upstream, serverId, claimed, signal, reopen) {
|
|
2715
4079
|
let first = true;
|
|
2716
4080
|
let current = upstream;
|
|
2717
4081
|
// The in-place reopen backoff (CP4, the merged pumps' discipline): starts
|
|
@@ -2732,6 +4096,12 @@ export function installIntercept(options) {
|
|
|
2732
4096
|
// closed-session entry for it is stale (T34).
|
|
2733
4097
|
clearClosed(claimed);
|
|
2734
4098
|
}
|
|
4099
|
+
// P55-2: the snapshot's header carries the session identity and its
|
|
4100
|
+
// cwd in ORIGINAL ids — record before the rewrite below virtualizes
|
|
4101
|
+
// them, so /api/file can attribute the session's own image paths.
|
|
4102
|
+
if (endpoint === 'session/follow' && isPlainObject(frame) && frame.type === 'snapshot' && isPlainObject(frame.header)) {
|
|
4103
|
+
recordRemoteSessionCwd(serverId, frame.header.id, frame.header.cwd);
|
|
4104
|
+
}
|
|
2735
4105
|
yield rewriteFrame(endpoint, frame, serverId);
|
|
2736
4106
|
}
|
|
2737
4107
|
return;
|
|
@@ -2793,7 +4163,7 @@ export function installIntercept(options) {
|
|
|
2793
4163
|
closedClearWaiters.delete(onCleared);
|
|
2794
4164
|
return;
|
|
2795
4165
|
}
|
|
2796
|
-
throw new CodedStreamError(code,
|
|
4166
|
+
throw new CodedStreamError(code, copy().closed);
|
|
2797
4167
|
}
|
|
2798
4168
|
recordFailure(endpoint, code);
|
|
2799
4169
|
// A link-down fact (CP4), scoped by CP5: only the carrier-retry
|
|
@@ -2806,7 +4176,7 @@ export function installIntercept(options) {
|
|
|
2806
4176
|
if (!SESSION_STREAM_HOLD_ENDPOINTS.has(endpoint)) {
|
|
2807
4177
|
throw new CodedStreamError(code, messageOf(error));
|
|
2808
4178
|
}
|
|
2809
|
-
const serve = await waitServeAgain(signal, reopenDelayMs);
|
|
4179
|
+
const serve = await waitServeAgain(client, signal, reopenDelayMs);
|
|
2810
4180
|
if (serve === 'aborted')
|
|
2811
4181
|
return;
|
|
2812
4182
|
// The pairing wall came down while held (CP5): no ladder climbs back
|
|
@@ -2816,7 +4186,8 @@ export function installIntercept(options) {
|
|
|
2816
4186
|
// would freeze the panel with no verdict and no wake-up.
|
|
2817
4187
|
if (serve !== 'online') {
|
|
2818
4188
|
recordFailure(endpoint, serve);
|
|
2819
|
-
|
|
4189
|
+
const c = copy();
|
|
4190
|
+
throw new CodedStreamError(serve, serve === 'revoked' ? c.revoked : c.unpaired);
|
|
2820
4191
|
}
|
|
2821
4192
|
if (callerAborted())
|
|
2822
4193
|
return;
|
|
@@ -2833,6 +4204,11 @@ export function installIntercept(options) {
|
|
|
2833
4204
|
* virtualized session ids. Paged requests, an offline relay, a non-ok local
|
|
2834
4205
|
* envelope and a failed remote call all answer the local result untouched —
|
|
2835
4206
|
* only a remote failure is recorded.
|
|
4207
|
+
*
|
|
4208
|
+
* MS-3 pool mode: EVERY online server's first page is fetched
|
|
4209
|
+
* concurrently and folded in `pool.entries()` order (pairing order), one
|
|
4210
|
+
* `mergeSessionList` per server. A failing leg is recorded and skipped —
|
|
4211
|
+
* the other servers' rows still merge.
|
|
2836
4212
|
*/
|
|
2837
4213
|
async function mergedSessionListCall(endpoint, payload, args, signal, peer) {
|
|
2838
4214
|
const localEnvelope = (await chainTarget.dispatchRpc.call(raw, endpoint, payload, signal, peer));
|
|
@@ -2843,11 +4219,30 @@ export function installIntercept(options) {
|
|
|
2843
4219
|
const request = isPlainObject(args) ? args._request : undefined;
|
|
2844
4220
|
if (isPlainObject(request) && request.cursor !== undefined)
|
|
2845
4221
|
return localEnvelope;
|
|
2846
|
-
|
|
2847
|
-
|
|
4222
|
+
if (pool !== undefined) {
|
|
4223
|
+
const online = pool.entries().filter((entry) => entry.serverId !== undefined && entry.client.state === 'online');
|
|
4224
|
+
if (online.length === 0)
|
|
4225
|
+
return localEnvelope;
|
|
4226
|
+
const settled = await Promise.all(online.map((entry) => entry.client.invoke('session', 'list', isPlainObject(args) ? args : {}, signal).then((value) => ({ ok: true, value, serverId: entry.serverId }), (error) => ({ ok: false, error, serverId: entry.serverId }))));
|
|
4227
|
+
let value = localEnvelope.value;
|
|
4228
|
+
for (const result of settled) {
|
|
4229
|
+
if (!result.ok) {
|
|
4230
|
+
recordFailure(endpoint, result.error instanceof RelayError ? result.error.code : 'internal');
|
|
4231
|
+
continue;
|
|
4232
|
+
}
|
|
4233
|
+
// P55-2: the RAW remote rows (original ids) feed the /api/file cwd
|
|
4234
|
+
// registry before the merge virtualizes them.
|
|
4235
|
+
noteRemoteSessionListCwds(result.value, result.serverId);
|
|
4236
|
+
value = mergeSessionList(value, result.value, result.serverId);
|
|
4237
|
+
}
|
|
4238
|
+
return { ok: true, value };
|
|
4239
|
+
}
|
|
4240
|
+
const identity = relayIdentityOf(singleRelay);
|
|
4241
|
+
if (identity === undefined || singleRelay.state !== 'online')
|
|
2848
4242
|
return localEnvelope;
|
|
2849
4243
|
try {
|
|
2850
|
-
const remote = await
|
|
4244
|
+
const remote = await singleRelay.invoke('session', 'list', isPlainObject(args) ? args : {}, signal);
|
|
4245
|
+
noteRemoteSessionListCwds(remote, identity.serverId);
|
|
2851
4246
|
return { ok: true, value: mergeSessionList(localEnvelope.value, remote, identity.serverId) };
|
|
2852
4247
|
}
|
|
2853
4248
|
catch (error) {
|
|
@@ -2880,8 +4275,48 @@ export function installIntercept(options) {
|
|
|
2880
4275
|
// A local failure is the answer — there is nothing to merge into.
|
|
2881
4276
|
if (!isPlainObject(localEnvelope) || localEnvelope.ok !== true)
|
|
2882
4277
|
return localEnvelope;
|
|
2883
|
-
|
|
2884
|
-
|
|
4278
|
+
if (pool !== undefined) {
|
|
4279
|
+
// MS-3: every PAIRED server contributes its own groups, folded in
|
|
4280
|
+
// pairing order; each server's last fetched catalog is cached by its
|
|
4281
|
+
// serverId so an offline server keeps its groups while the online ones
|
|
4282
|
+
// refetch. A cache whose server left the pool dies with the entry.
|
|
4283
|
+
const entries = pool.entries().filter((entry) => entry.serverId !== undefined);
|
|
4284
|
+
if (entries.length === 0)
|
|
4285
|
+
return localEnvelope;
|
|
4286
|
+
const online = entries.filter((entry) => entry.client.state === 'online');
|
|
4287
|
+
const settled = online.length === 0
|
|
4288
|
+
? []
|
|
4289
|
+
: await Promise.all(online.map((entry) => entry.client.invoke('session', 'modelCatalog', {}, signal).then((value) => ({ ok: true, value, serverId: entry.serverId }), (error) => ({ ok: false, error, serverId: entry.serverId }))));
|
|
4290
|
+
const resultsByServer = new Map(settled.map((result) => [result.serverId, result]));
|
|
4291
|
+
let acc = localEnvelope.value;
|
|
4292
|
+
for (const entry of entries) {
|
|
4293
|
+
const serverId = entry.serverId;
|
|
4294
|
+
const identity = { serverId, serverName: entry.displayName };
|
|
4295
|
+
const result = resultsByServer.get(serverId);
|
|
4296
|
+
if (result === undefined) {
|
|
4297
|
+
const cached = serverCatalogCaches.get(serverId);
|
|
4298
|
+
if (cached !== undefined)
|
|
4299
|
+
acc = mergeModelCatalogs(acc, cached.value, cached.identity);
|
|
4300
|
+
continue;
|
|
4301
|
+
}
|
|
4302
|
+
if (!result.ok) {
|
|
4303
|
+
recordFailure(endpoint, result.error instanceof RelayError ? result.error.code : 'internal');
|
|
4304
|
+
const cached = serverCatalogCaches.get(serverId);
|
|
4305
|
+
if (cached !== undefined)
|
|
4306
|
+
acc = mergeModelCatalogs(acc, cached.value, cached.identity);
|
|
4307
|
+
continue;
|
|
4308
|
+
}
|
|
4309
|
+
serverCatalogCaches.set(serverId, { identity, value: result.value });
|
|
4310
|
+
acc = mergeModelCatalogs(acc, result.value, identity);
|
|
4311
|
+
}
|
|
4312
|
+
for (const serverId of [...serverCatalogCaches.keys()]) {
|
|
4313
|
+
if (!entries.some((entry) => entry.serverId === serverId))
|
|
4314
|
+
serverCatalogCaches.delete(serverId);
|
|
4315
|
+
}
|
|
4316
|
+
return { ok: true, value: acc };
|
|
4317
|
+
}
|
|
4318
|
+
const identity = relayIdentityOf(singleRelay);
|
|
4319
|
+
if (identity !== undefined && singleRelay.state === 'online') {
|
|
2885
4320
|
try {
|
|
2886
4321
|
// The method has NO parameters (RT lib/typert.remote-client.js:1019) —
|
|
2887
4322
|
// the relay route allows it field-less (relay-access.ts), filters the
|
|
@@ -2892,7 +4327,7 @@ export function installIntercept(options) {
|
|
|
2892
4327
|
// the counter and the answer never re-enters the cache — the caller
|
|
2893
4328
|
// still gets its (live) answer, it asked while the link served.
|
|
2894
4329
|
const generation = catalogGeneration;
|
|
2895
|
-
const remote = await
|
|
4330
|
+
const remote = await singleRelay.invoke('session', 'modelCatalog', {}, signal);
|
|
2896
4331
|
rememberServerCatalog(identity, remote, generation);
|
|
2897
4332
|
return { ok: true, value: mergeModelCatalogs(localEnvelope.value, remote, identity) };
|
|
2898
4333
|
}
|
|
@@ -2903,7 +4338,7 @@ export function installIntercept(options) {
|
|
|
2903
4338
|
}
|
|
2904
4339
|
// Not serving normally. A pairing wall is a verdict about the server
|
|
2905
4340
|
// itself — its groups must not outlive it.
|
|
2906
|
-
if (
|
|
4341
|
+
if (singleRelay.state === 'unpaired' || singleRelay.state === 'revoked') {
|
|
2907
4342
|
serverCatalogCache = undefined;
|
|
2908
4343
|
return localEnvelope;
|
|
2909
4344
|
}
|
|
@@ -2964,17 +4399,29 @@ export function installIntercept(options) {
|
|
|
2964
4399
|
const providerParts = fromVirtual(provider);
|
|
2965
4400
|
if (sessionParts === undefined && providerParts === undefined)
|
|
2966
4401
|
return undefined;
|
|
4402
|
+
const c = copy();
|
|
2967
4403
|
if (sessionParts === undefined) {
|
|
2968
|
-
return { code: 'remote-unsupported', message:
|
|
4404
|
+
return { code: 'remote-unsupported', message: c.modelGroupRemote };
|
|
2969
4405
|
}
|
|
2970
|
-
|
|
4406
|
+
// MS-3: the session's server is looked up in the POOL (by the virtual
|
|
4407
|
+
// id's serverId); a pool without that server falls through to the
|
|
4408
|
+
// generic path's verdict (offline / mismatch). The message names the
|
|
4409
|
+
// pool's display name — the same `-1`/`-2` disambiguated one the merged
|
|
4410
|
+
// catalog groups are titled with.
|
|
4411
|
+
const displayName = (() => {
|
|
4412
|
+
if (pool !== undefined)
|
|
4413
|
+
return pool.getByServerId(sessionParts.serverId)?.displayName;
|
|
4414
|
+
if (singleGetServerId() !== sessionParts.serverId)
|
|
4415
|
+
return undefined;
|
|
4416
|
+
return relayIdentityOf(singleRelay)?.serverName;
|
|
4417
|
+
})();
|
|
4418
|
+
if (displayName === undefined)
|
|
2971
4419
|
return undefined;
|
|
2972
4420
|
if (providerParts === undefined) {
|
|
2973
|
-
|
|
2974
|
-
return { code: 'remote-unsupported', message: `请选择「${serverName} · …」分组里的模型` };
|
|
4421
|
+
return { code: 'remote-unsupported', message: c.modelGroupPickServer(displayName) };
|
|
2975
4422
|
}
|
|
2976
4423
|
if (providerParts.serverId !== sessionParts.serverId) {
|
|
2977
|
-
return { code: 'remote-mismatch', message:
|
|
4424
|
+
return { code: 'remote-mismatch', message: c.modelGroupMismatch };
|
|
2978
4425
|
}
|
|
2979
4426
|
return { args: { ...args, request: { ...request, provider: providerParts.id } } };
|
|
2980
4427
|
}
|
|
@@ -3046,13 +4493,30 @@ export function installIntercept(options) {
|
|
|
3046
4493
|
const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control, uplinkWindow);
|
|
3047
4494
|
if (!isAsyncIterable(local))
|
|
3048
4495
|
return local;
|
|
3049
|
-
|
|
4496
|
+
if (pool !== undefined) {
|
|
4497
|
+
return mergedEventsStreamMulti({ local, pool, signal, recordFailure, log, clock, summarySync, watchPool });
|
|
4498
|
+
}
|
|
4499
|
+
return mergedEventsStream({ local, relay: singleRelay, signal, recordFailure, log, clock, summarySync });
|
|
3050
4500
|
})();
|
|
3051
4501
|
}
|
|
3052
4502
|
const args = argsOf(payload);
|
|
3053
4503
|
const fields = CLIENT_METHOD_FIELDS[endpoint];
|
|
3054
4504
|
const virtuals = collectVirtuals(fields, args);
|
|
3055
4505
|
if (virtuals.length === 0) {
|
|
4506
|
+
// P55-2: a LOCAL session/follow (a well-formed session address whose id
|
|
4507
|
+
// is not virtual) is the newest fact about which page the user is on —
|
|
4508
|
+
// recorded at the same instant the wrap is reached, so a later image
|
|
4509
|
+
// request never resolves against the remote session just left.
|
|
4510
|
+
if (endpoint === 'session/follow') {
|
|
4511
|
+
const address = isPlainObject(args) && isPlainObject(args.request) ? args.request.address : undefined;
|
|
4512
|
+
if (isPlainObject(address) &&
|
|
4513
|
+
address.kind === 'session' &&
|
|
4514
|
+
typeof address.sessionId === 'string' &&
|
|
4515
|
+
address.sessionId !== '' &&
|
|
4516
|
+
!isVirtual(address.sessionId)) {
|
|
4517
|
+
lastFollowedSession = { kind: 'local' };
|
|
4518
|
+
}
|
|
4519
|
+
}
|
|
3056
4520
|
if (endpoint === 'workspace/follow' || endpoint === 'session/control') {
|
|
3057
4521
|
// The startup self-check probe wants the LOCAL baseline only — no
|
|
3058
4522
|
// remote leg, no merge (its payload is registered in probePayloads).
|
|
@@ -3073,12 +4537,32 @@ export function installIntercept(options) {
|
|
|
3073
4537
|
const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control, uplinkWindow);
|
|
3074
4538
|
if (!isAsyncIterable(local))
|
|
3075
4539
|
return local;
|
|
4540
|
+
if (pool !== undefined) {
|
|
4541
|
+
return mergedGlobalStreamMulti({
|
|
4542
|
+
endpoint,
|
|
4543
|
+
namespace,
|
|
4544
|
+
method,
|
|
4545
|
+
local,
|
|
4546
|
+
pool,
|
|
4547
|
+
signal,
|
|
4548
|
+
recordFailure,
|
|
4549
|
+
log,
|
|
4550
|
+
clock,
|
|
4551
|
+
getCurrentSession,
|
|
4552
|
+
watchCurrentSession,
|
|
4553
|
+
watchUninstall,
|
|
4554
|
+
summarySync,
|
|
4555
|
+
createSessionInventorySlot,
|
|
4556
|
+
watchPool,
|
|
4557
|
+
lang: options.lang,
|
|
4558
|
+
});
|
|
4559
|
+
}
|
|
3076
4560
|
return mergedGlobalStream({
|
|
3077
4561
|
endpoint,
|
|
3078
4562
|
namespace,
|
|
3079
4563
|
method,
|
|
3080
4564
|
local,
|
|
3081
|
-
relay,
|
|
4565
|
+
relay: singleRelay,
|
|
3082
4566
|
signal,
|
|
3083
4567
|
recordFailure,
|
|
3084
4568
|
log,
|
|
@@ -3087,30 +4571,50 @@ export function installIntercept(options) {
|
|
|
3087
4571
|
watchCurrentSession,
|
|
3088
4572
|
watchUninstall,
|
|
3089
4573
|
summarySync,
|
|
3090
|
-
|
|
4574
|
+
lang: options.lang,
|
|
4575
|
+
...(createSessionInventorySlot !== undefined
|
|
4576
|
+
? { createSessionInventorySlot: () => createSessionInventorySlot(undefined) }
|
|
4577
|
+
: {}),
|
|
3091
4578
|
});
|
|
3092
4579
|
})();
|
|
3093
4580
|
}
|
|
3094
4581
|
return chainTarget.openWireStream.call(raw, endpoint, payload, uplink, peer, signal, control, uplinkWindow);
|
|
3095
4582
|
}
|
|
3096
4583
|
const verdict = validate(endpoint, virtuals);
|
|
3097
|
-
if (!verdict.ok)
|
|
4584
|
+
if (!verdict.ok) {
|
|
4585
|
+
// TA-2: a refusal must not leak the host's UplinkInbox — half-close it
|
|
4586
|
+
// before the coded throw, the same courtesy the forwarded path's own
|
|
4587
|
+
// failure paths extend below.
|
|
4588
|
+
releaseUplink(uplink);
|
|
3098
4589
|
throwRecorded(endpoint, verdict.code, verdict.message);
|
|
4590
|
+
}
|
|
3099
4591
|
// A WRITE stream while the relay is not serving (CP4, the invoke route's
|
|
3100
|
-
// T34 rule applied to the stream route):
|
|
3101
|
-
//
|
|
3102
|
-
// terminal
|
|
3103
|
-
//
|
|
3104
|
-
|
|
3105
|
-
|
|
4592
|
+
// T34 rule applied to the stream route): the write-shaped STREAMS of the
|
|
4593
|
+
// table — `terminal/follow`, and its 0.2.2 duplex successor
|
|
4594
|
+
// `terminal/attach` (REMOTE_UPLINK_STREAM_METHODS) — take over the
|
|
4595
|
+
// terminal's input control, so opening them into a dead link must refuse
|
|
4596
|
+
// here, exactly like every other mutation. MS-3: the TARGET leg's state
|
|
4597
|
+
// is the one that counts.
|
|
4598
|
+
if (isRemoteWrite(endpoint) && verdict.client.state !== 'online') {
|
|
4599
|
+
releaseUplink(uplink);
|
|
4600
|
+
throwRecorded(endpoint, 'remote-offline', copy().writeOffline);
|
|
3106
4601
|
}
|
|
3107
4602
|
// The multiplexed wire channel ALWAYS hands us an UplinkInbox here —
|
|
3108
|
-
// never undefined.
|
|
3109
|
-
// is half-closed and the stream forwarded (the
|
|
3110
|
-
// `$events` subscription, gateway releaseUplink).
|
|
3111
|
-
//
|
|
3112
|
-
|
|
4603
|
+
// never undefined. One-directional remote streams consume no uplink
|
|
4604
|
+
// items, so the uplink is half-closed and the stream forwarded (the
|
|
4605
|
+
// host's own move for its `$events` subscription, gateway releaseUplink).
|
|
4606
|
+
// TA-2: a DUPLEX member of REMOTE_UPLINK_STREAM_METHODS keeps its inbox —
|
|
4607
|
+
// the host's TerminalInput write/resize commands ride it, so the iterable
|
|
4608
|
+
// and the mux's uplink window (shape-checked, garbage dropped) travel to
|
|
4609
|
+
// the relay client's pump instead; the pump releases the iterable itself
|
|
4610
|
+
// when the stream ends. Rejecting uplinks would reject every remote
|
|
4611
|
+
// stream the UI opens.
|
|
4612
|
+
const forwardsUplink = REMOTE_UPLINK_STREAM_METHODS.has(endpoint) && isAsyncIterable(uplink);
|
|
4613
|
+
if (!forwardsUplink)
|
|
4614
|
+
releaseUplink(uplink);
|
|
4615
|
+
const normalizedWindow = forwardsUplink ? normalizeUplinkWindow(uplinkWindow) : undefined;
|
|
3113
4616
|
const serverId = verdict.serverId;
|
|
4617
|
+
const client = verdict.client;
|
|
3114
4618
|
const slash = endpoint.indexOf('/');
|
|
3115
4619
|
const namespace = slash === -1 ? endpoint : endpoint.slice(0, slash);
|
|
3116
4620
|
const method = slash === -1 ? '' : endpoint.slice(slash + 1);
|
|
@@ -3126,9 +4630,18 @@ export function installIntercept(options) {
|
|
|
3126
4630
|
// through the outage and reopens the leg when the relay serves again
|
|
3127
4631
|
// (CP4); every other endpoint's first read fails out with the original
|
|
3128
4632
|
// code (CP5 scope, the pre-CP4 behavior).
|
|
3129
|
-
upstream =
|
|
4633
|
+
upstream = forwardsUplink
|
|
4634
|
+
? client.openStream(namespace, method, clone, signal, {
|
|
4635
|
+
uplink: uplink,
|
|
4636
|
+
...(normalizedWindow !== undefined ? { uplinkWindow: normalizedWindow } : {}),
|
|
4637
|
+
})
|
|
4638
|
+
: client.openStream(namespace, method, clone, signal);
|
|
3130
4639
|
}
|
|
3131
4640
|
catch (error) {
|
|
4641
|
+
// TA-2: a synchronous openStream failure (an unpaired relay) strands
|
|
4642
|
+
// the forwarded inbox — the pump never started, so half-close it here.
|
|
4643
|
+
if (forwardsUplink)
|
|
4644
|
+
releaseUplink(uplink);
|
|
3132
4645
|
const code = error instanceof RelayError ? error.code : 'internal';
|
|
3133
4646
|
throwRecorded(endpoint, code, messageOf(error));
|
|
3134
4647
|
}
|
|
@@ -3141,7 +4654,27 @@ export function installIntercept(options) {
|
|
|
3141
4654
|
// the endpoints OUTSIDE the list end with the original code at once,
|
|
3142
4655
|
// their consumers rendering a clean end as a terminal (CP5,
|
|
3143
4656
|
// SESSION_STREAM_HOLD_ENDPOINTS).
|
|
3144
|
-
|
|
4657
|
+
const held = rewriteUpstream(endpoint, client, upstream, serverId, virtuals, signal, () => client.openStream(namespace, method, clone, signal));
|
|
4658
|
+
// P55-2: a remote session/follow is recorded the instant the wrap is
|
|
4659
|
+
// reached (before any frame, before any browser report) — the first
|
|
4660
|
+
// <img> of the page cannot race a 1s poll. The follow stays in
|
|
4661
|
+
// `activeRemoteFollows` for the generator's whole life and leaves in its
|
|
4662
|
+
// finally, so the map holds exactly the LIVE follows.
|
|
4663
|
+
if (endpoint === 'session/follow') {
|
|
4664
|
+
const claimed = virtuals[0];
|
|
4665
|
+
lastFollowedSession = { kind: 'remote', serverId: claimed.serverId, sessionId: claimed.id };
|
|
4666
|
+
const token = Symbol();
|
|
4667
|
+
activeRemoteFollows.set(token, { serverId: claimed.serverId, sessionId: claimed.id });
|
|
4668
|
+
return (async function* () {
|
|
4669
|
+
try {
|
|
4670
|
+
yield* held;
|
|
4671
|
+
}
|
|
4672
|
+
finally {
|
|
4673
|
+
activeRemoteFollows.delete(token);
|
|
4674
|
+
}
|
|
4675
|
+
})();
|
|
4676
|
+
}
|
|
4677
|
+
return held;
|
|
3145
4678
|
};
|
|
3146
4679
|
gateway.openWireStream = wrappedOpen;
|
|
3147
4680
|
gateway.dispatchRpc = wrappedDispatch;
|
|
@@ -3153,12 +4686,24 @@ export function installIntercept(options) {
|
|
|
3153
4686
|
if (!installed)
|
|
3154
4687
|
return;
|
|
3155
4688
|
installed = false;
|
|
4689
|
+
// P55-2: the attribution state dies with the wrap — the fetch-route
|
|
4690
|
+
// half reads the wiring's live `interceptHandle` slot, which the
|
|
4691
|
+
// uninstall clears, so this is hygiene; a stale map must never
|
|
4692
|
+
// outlive the interception that fed it.
|
|
4693
|
+
remoteSessionCwds.clear();
|
|
4694
|
+
activeRemoteFollows.clear();
|
|
4695
|
+
lastFollowedSession = undefined;
|
|
3156
4696
|
for (const listener of [...uninstallListeners])
|
|
3157
4697
|
listener();
|
|
3158
4698
|
// The catalog watcher dies with the wrap (T52-fix2): a late online
|
|
3159
|
-
// transition after this point belongs to another generation.
|
|
4699
|
+
// transition after this point belongs to another generation. MS-5: in
|
|
4700
|
+
// pool mode that includes every per-entry client subscription the
|
|
4701
|
+
// rescan created — the rescan's own unsubscribe is not enough.
|
|
3160
4702
|
offCatalogWatch?.();
|
|
3161
4703
|
offCatalogWatch = undefined;
|
|
4704
|
+
for (const unsubscribe of catalogWatched.values())
|
|
4705
|
+
unsubscribe();
|
|
4706
|
+
catalogWatched.clear();
|
|
3162
4707
|
for (const name of ['openWireStream', 'dispatchRpc']) {
|
|
3163
4708
|
// Only replace/delete while the property still holds OUR wrapper.
|
|
3164
4709
|
// A later plugin may have wrapped OVER us: its wrapper captured this
|
|
@@ -3198,6 +4743,14 @@ export function installIntercept(options) {
|
|
|
3198
4743
|
if (virtualIds.length > 0)
|
|
3199
4744
|
summarySync.noteForwardedSessionIds(virtualIds);
|
|
3200
4745
|
},
|
|
4746
|
+
notePoolEvent() {
|
|
4747
|
+
// MS-3: broadcast to every pool watcher — the live merged streams'
|
|
4748
|
+
// re-scans and the pool-mode catalog watcher (already registered as
|
|
4749
|
+
// one of the listeners in pool mode). No-op in single-server mode.
|
|
4750
|
+
for (const listener of [...poolListeners])
|
|
4751
|
+
listener();
|
|
4752
|
+
},
|
|
4753
|
+
resolveRemoteFileScope,
|
|
3201
4754
|
};
|
|
3202
4755
|
}
|
|
3203
4756
|
/**
|