dsh-zen-remote 2.0.0 → 2.0.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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/remote-access.md +2 -2
- package/lib/client.js +255 -21
- package/lib/fingerprint.js +4 -2
- package/lib/fingerprint.js.map +1 -1
- package/lib/intercept.js +152 -11
- package/lib/intercept.js.map +1 -1
- package/lib/merge-streams.js +11 -4
- package/lib/merge-streams.js.map +1 -1
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-server.js +32 -11
- package/lib/relay-server.js.map +1 -1
- package/lib/types/client/effects/model-group-side.d.ts +13 -0
- package/lib/types/client/effects/model-group-side.d.ts.map +1 -0
- package/lib/types/client/index.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-data/model-group-side.d.ts +44 -0
- package/lib/types/client-data/model-group-side.d.ts.map +1 -0
- package/lib/types/fingerprint.d.ts +4 -2
- package/lib/types/fingerprint.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +19 -9
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +7 -2
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/virtual-id.d.ts +14 -0
- package/lib/types/virtual-id.d.ts.map +1 -1
- package/lib/virtual-id.js +14 -0
- package/lib/virtual-id.js.map +1 -1
- package/package.json +1 -1
- package/src/client/effects/model-group-side.ts +132 -0
- package/src/client/index.tsx +7 -0
- package/src/client/styles/remote-session.css.ts +43 -20
- package/src/client-data/model-group-side.ts +65 -0
- package/src/fingerprint.ts +4 -2
- package/src/intercept.ts +158 -10
- package/src/merge-streams.ts +11 -4
- package/src/relay-access.ts +6 -3
- package/src/relay-server.ts +49 -11
- package/src/virtual-id.ts +15 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T74: which side of the pairing a model-menu GROUP belongs to, as pure
|
|
3
|
+
* functions — the readable core of the browser effect
|
|
4
|
+
* (src/client/effects/model-group-side.ts).
|
|
5
|
+
*
|
|
6
|
+
* WHY: the merged catalog (merge-streams.ts mergeModelCatalogs) appends the
|
|
7
|
+
* server's provider groups everywhere, but a selection only runs on the side
|
|
8
|
+
* the open session lives on — the interceptor refuses the other side. Through
|
|
9
|
+
* DSH 0.1.7 every menu group carried its id in `aria-labelledby`
|
|
10
|
+
* (`<useId>-<group.id>`), so CSS keyed the hiding on the `zr~` prefix (T52).
|
|
11
|
+
* DSH 0.2.0 renders the groups with the primitives' MenuGroup, whose
|
|
12
|
+
* `aria-labelledby` is a bare useId — the id is gone — and the same component
|
|
13
|
+
* renders OTHER menus' groups too, so attribute scoping misfires in both
|
|
14
|
+
* directions. Classification therefore moved to the one mark every version
|
|
15
|
+
* renders: virtual group NAMES start with VIRTUAL_GROUP_NAME_MARK
|
|
16
|
+
* (virtual-id.ts), read off each group's heading text.
|
|
17
|
+
*
|
|
18
|
+
* The container rule is what keeps the effect inert wherever it cannot be
|
|
19
|
+
* sure: a container with not ONE marked heading — the model menu of a
|
|
20
|
+
* sub-client with no shared models, every other menu on the page, the whole
|
|
21
|
+
* rest of the page — classifies as `undefined` for every group, which the
|
|
22
|
+
* effect reads as "leave the DOM alone". Only a container holding at least
|
|
23
|
+
* one marked heading is decided, and inside it the unmarked groups are
|
|
24
|
+
* exactly the LOCAL ones: the merged catalog only ever APPENDS virtual
|
|
25
|
+
* groups to the local list, never mixes them the other way.
|
|
26
|
+
*
|
|
27
|
+
* Defensive like the rest of client-data: an empty or missing heading never
|
|
28
|
+
* counts as a mark, so it can never decide a container by accident.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { VIRTUAL_GROUP_NAME_MARK } from '../virtual-id.ts'
|
|
32
|
+
|
|
33
|
+
/** One group's display description, as the DOM shows it. */
|
|
34
|
+
export interface ModelGroupHeading {
|
|
35
|
+
/** Groups sharing one container share one key (the effect numbers the
|
|
36
|
+
* parent elements it scans). */
|
|
37
|
+
containerKey: number
|
|
38
|
+
/** The group's heading text ('' when no heading could be found). */
|
|
39
|
+
headingText: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Per-group verdict, in input order: `remote` for marked headings, `local`
|
|
43
|
+
* for the rest of a container that has at least one, and `undefined` —
|
|
44
|
+
* "stamp nothing" — for every group of a container that holds no mark at
|
|
45
|
+
* all. */
|
|
46
|
+
export type ModelGroupSide = 'remote' | 'local' | undefined
|
|
47
|
+
|
|
48
|
+
export function classifyModelGroups(groups: readonly ModelGroupHeading[]): ModelGroupSide[] {
|
|
49
|
+
// Per container: did any heading carry the mark? Containers are decided
|
|
50
|
+
// independently — one mixed model menu must not turn an all-local
|
|
51
|
+
// container (a second menu elsewhere on the page) into "hide everything
|
|
52
|
+
// local" there.
|
|
53
|
+
const marked = new Map<number, boolean>()
|
|
54
|
+
for (const group of groups) {
|
|
55
|
+
const had = marked.get(group.containerKey) ?? false
|
|
56
|
+
marked.set(group.containerKey, had || group.headingText.startsWith(VIRTUAL_GROUP_NAME_MARK))
|
|
57
|
+
}
|
|
58
|
+
return groups.map((group) =>
|
|
59
|
+
marked.get(group.containerKey)
|
|
60
|
+
? group.headingText.startsWith(VIRTUAL_GROUP_NAME_MARK)
|
|
61
|
+
? 'remote'
|
|
62
|
+
: 'local'
|
|
63
|
+
: undefined,
|
|
64
|
+
)
|
|
65
|
+
}
|
package/src/fingerprint.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* definitions, the server puts its map into the handshake, the client
|
|
9
9
|
* compares group by group, and the settings page lists the differing groups.
|
|
10
10
|
* Identical maps mean fully compatible — the DSH version numbers themselves
|
|
11
|
-
* are deliberately NOT compared (docs/spike-relay.md §2.3: 0.1.7 and
|
|
12
|
-
* share byte-identical remote interfaces, so a version gate would
|
|
11
|
+
* are deliberately NOT compared (docs/spike-relay.md §2.3: 0.1.7-rc.2 and
|
|
12
|
+
* 0.2.0-rc.1 share byte-identical remote interfaces, so a version gate would
|
|
13
|
+
* misreject; 0.2.0-rc.2 then changed the session group within one minor —
|
|
14
|
+
* an additive optional field — which a version gate would have missed).
|
|
13
15
|
*
|
|
14
16
|
* What is hashed, per group, in priority order:
|
|
15
17
|
*
|
package/src/intercept.ts
CHANGED
|
@@ -57,11 +57,14 @@
|
|
|
57
57
|
* `agentId` and `eventId` become `zr~<serverId>~…` virtual ids (those are
|
|
58
58
|
* the ONLY session ids a forwarded waterfall carries — the request bodies
|
|
59
59
|
* are tool/question data, verified against dsh-tools/dsh-user-questions),
|
|
60
|
-
* and the remote `ready`/`emit` frames are DROPPED
|
|
60
|
+
* and the remote `ready`/`emit` frames are DROPPED — except, since T75, a
|
|
61
|
+
* shared session's `api-session/status` emit, which crosses with its
|
|
62
|
+
* session id virtualized (the one share-scoped emit; every other emit
|
|
63
|
+
* broadcasts server-wide state the UI must not mistake for local
|
|
64
|
+
* sessions). The client face of the
|
|
61
65
|
* gateway (dsh-api-gateway lib/client.js) accepts a ready frame only as
|
|
62
|
-
* the FIRST frame of the stream and would fail the stream on a second one
|
|
63
|
-
*
|
|
64
|
-
* local sessions. Prompts this leg showed are closed when the leg dies —
|
|
66
|
+
* the FIRST frame of the stream and would fail the stream on a second one.
|
|
67
|
+
* Prompts this leg showed are closed when the leg dies —
|
|
65
68
|
* each gets a synthesized `cancel` — so a disconnect cannot leave an
|
|
66
69
|
* approval on screen that no server can settle anymore (T32-fix).
|
|
67
70
|
* - `dispatchRpc('$events/result', …)` splits on the eventId: a virtual id
|
|
@@ -668,6 +671,14 @@ interface ForwardableCancelFrame extends Record<string, unknown> {
|
|
|
668
671
|
eventId: string
|
|
669
672
|
}
|
|
670
673
|
|
|
674
|
+
/** T75: the one emit this client shows the UI (T75 — the server forwards no
|
|
675
|
+
* other). Same shape the server's own gate proved before forwarding. */
|
|
676
|
+
interface ForwardableStatusEmitFrame extends Record<string, unknown> {
|
|
677
|
+
type: 'emit'
|
|
678
|
+
event: 'api-session/status'
|
|
679
|
+
args: [string, boolean]
|
|
680
|
+
}
|
|
681
|
+
|
|
671
682
|
function isForwardableWaterfall(frame: Record<string, unknown>): frame is ForwardableWaterfallFrame {
|
|
672
683
|
return (
|
|
673
684
|
Reflect.ownKeys(frame).length === 5 &&
|
|
@@ -690,6 +701,18 @@ function isForwardableCancel(frame: Record<string, unknown>): frame is Forwardab
|
|
|
690
701
|
)
|
|
691
702
|
}
|
|
692
703
|
|
|
704
|
+
function isForwardableStatusEmit(frame: Record<string, unknown>): frame is ForwardableStatusEmitFrame {
|
|
705
|
+
return (
|
|
706
|
+
Reflect.ownKeys(frame).length === 3 &&
|
|
707
|
+
['type', 'event', 'args'].every((key) => Object.hasOwn(frame, key)) &&
|
|
708
|
+
frame.event === 'api-session/status' &&
|
|
709
|
+
Array.isArray(frame.args) &&
|
|
710
|
+
frame.args.length === 2 &&
|
|
711
|
+
isEventId(frame.args[0]) &&
|
|
712
|
+
typeof frame.args[1] === 'boolean'
|
|
713
|
+
)
|
|
714
|
+
}
|
|
715
|
+
|
|
693
716
|
function messageOf(error: unknown): string {
|
|
694
717
|
return error instanceof Error ? error.message : String(error)
|
|
695
718
|
}
|
|
@@ -1116,11 +1139,18 @@ export function rewriteResult(endpoint: string, value: unknown, serverId: string
|
|
|
1116
1139
|
* whole thing in `zr~<serverId>~`.
|
|
1117
1140
|
* - `cancel`: `{type, eventId}` — the correlation id goes virtual so the UI
|
|
1118
1141
|
* can match it against the waterfall it showed and close the prompt.
|
|
1119
|
-
* -
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1142
|
+
* - `emit`: dropped, with ONE exception (T75) — a well-formed
|
|
1143
|
+
* `api-session/status` frame (the only emit the server forwards, and only
|
|
1144
|
+
* for accessible sessions) has its `args[0]` session id virtualized and
|
|
1145
|
+
* its `args[1]` running flag passed through, so the UI's
|
|
1146
|
+
* `api-session/status` listener keeps the remote session's running state
|
|
1147
|
+
* in step with the host's (RT dsh-api-session-controller lib/client.js
|
|
1148
|
+
* `handleSessionStatus`).
|
|
1149
|
+
* - everything else — `ready`, non-status emits, non-objects, unknown
|
|
1150
|
+
* types, and any frame failing the client face's exact-keys shape — is
|
|
1151
|
+
* DROPPED (T32-fix2 mirrors the server side). `ready` and the other emits
|
|
1152
|
+
* carry facts the UI must never see (the local stream opened with ITS
|
|
1153
|
+
* ready frame; emits broadcast server-wide state); a malformed frame that
|
|
1124
1154
|
* slipped through would fail `parseRemoteEventFrame` and take the UI's
|
|
1125
1155
|
* whole `$events` generation down with it, failing and reconnecting in a
|
|
1126
1156
|
* loop. Mirrors the server's own forwardable-shape gate, so the two ends
|
|
@@ -1130,7 +1160,10 @@ export function rewriteRemoteEventFrame(frame: unknown, serverId: string): unkno
|
|
|
1130
1160
|
const virtualize = (id: string): string => toVirtual(serverId, id)
|
|
1131
1161
|
if (!isPlainObject(frame)) return null
|
|
1132
1162
|
if (frame.type === 'ready') return null
|
|
1133
|
-
if (frame.type === 'emit')
|
|
1163
|
+
if (frame.type === 'emit') {
|
|
1164
|
+
if (!isForwardableStatusEmit(frame)) return null
|
|
1165
|
+
return { type: 'emit', event: frame.event, args: [virtualize(frame.args[0]), frame.args[1]] }
|
|
1166
|
+
}
|
|
1134
1167
|
if (frame.type === 'waterfall') {
|
|
1135
1168
|
if (!isForwardableWaterfall(frame)) return null
|
|
1136
1169
|
return { ...frame, eventId: virtualize(frame.eventId), agentId: virtualize(frame.agentId) }
|
|
@@ -1791,6 +1824,17 @@ interface MergedEventsStreamDeps {
|
|
|
1791
1824
|
* id the server did not return is retried on a 2s/10s backoff at most
|
|
1792
1825
|
* twice before it counts as asked for good (a tombstone triggers at most
|
|
1793
1826
|
* three syncs), and a fresh serving period clears the bookkeeping.
|
|
1827
|
+
*
|
|
1828
|
+
* T75 adds the serving-period STATUS sync: the UI's `session.running` for a
|
|
1829
|
+
* remote session updates only on the forwarded `api-session/status` emit
|
|
1830
|
+
* (RT dsh-api-session-controller lib/client.js `handleSessionStatus`) and
|
|
1831
|
+
* the list summaries — so every flip inside an offline window was lost, and
|
|
1832
|
+
* a session that ended (or started) while the link was down kept its stale
|
|
1833
|
+
* badge until the next page load. Every new serving period now re-pulls the
|
|
1834
|
+
* list once and pushes one synthesized status frame per row (see
|
|
1835
|
+
* {@link runStatusSync}), guarded against overwriting a real-time frame
|
|
1836
|
+
* that landed mid-fetch, and gated on the local `ready` like every
|
|
1837
|
+
* synthesized frame.
|
|
1794
1838
|
*/
|
|
1795
1839
|
async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator<unknown, void, undefined> {
|
|
1796
1840
|
const { local, relay, signal, recordFailure, log, clock } = deps
|
|
@@ -1969,6 +2013,90 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
|
|
|
1969
2013
|
if (!ids.some((id) => !announced.has(id) && !askedForGood.has(id) && !retryAt.has(id))) return
|
|
1970
2014
|
runSummarySync(ids)
|
|
1971
2015
|
}
|
|
2016
|
+
// T75 status-sync bookkeeping. `statusSeq` is a monotonic counter over
|
|
2017
|
+
// status facts; `liveStatusSeq` maps session id → the seq of the newest
|
|
2018
|
+
// REAL-TIME status frame the leg delivered since the in-flight fetch began
|
|
2019
|
+
// (cleared at each fetch start, so it only ever holds frames NEWER than
|
|
2020
|
+
// the snapshot being fetched — exactly the ids the snapshot must not
|
|
2021
|
+
// overwrite). `deferred*`/`pending*` mirror the summary sync's gates: the
|
|
2022
|
+
// local `ready` must come first, and a serving period that began while a
|
|
2023
|
+
// fetch ran gets its own round once that fetch lands.
|
|
2024
|
+
let statusSeq = 0
|
|
2025
|
+
const liveStatusSeq = new Map<string, number>()
|
|
2026
|
+
let statusSyncInFlight = false
|
|
2027
|
+
let pendingStatusSync = false
|
|
2028
|
+
let deferredStatusSync = false
|
|
2029
|
+
/**
|
|
2030
|
+
* T75: fetch the server's `session/list` once and push one synthesized
|
|
2031
|
+
* `api-session/status` emit per row (args `[virtualId, row.running]`) — a
|
|
2032
|
+
* NEW serving period's re-sync, because the status flips that happened
|
|
2033
|
+
* while the relay was offline never traveled (the forwarded status emit
|
|
2034
|
+
* only exists while the leg is up, and `runSummarySync`'s one-time row
|
|
2035
|
+
* announcement is deduped by `announced`, so the running flags the UI
|
|
2036
|
+
* holds would stay frozen at page-load state). Runs beside the summary
|
|
2037
|
+
* sync per serving period (its own fetch: the summary round is nudge-
|
|
2038
|
+
* driven and announce-deduped, and bolting a status mode onto it would
|
|
2039
|
+
* tangle both features' bookkeeping for one saved request), gated on the
|
|
2040
|
+
* local `ready` like every synthesized frame, and a failure is
|
|
2041
|
+
* diagnostics only — the next serving period retries.
|
|
2042
|
+
*/
|
|
2043
|
+
const runStatusSync = (): void => {
|
|
2044
|
+
if (!alive) return
|
|
2045
|
+
if (!localOpened) {
|
|
2046
|
+
deferredStatusSync = true
|
|
2047
|
+
return
|
|
2048
|
+
}
|
|
2049
|
+
if (statusSyncInFlight) {
|
|
2050
|
+
pendingStatusSync = true
|
|
2051
|
+
return
|
|
2052
|
+
}
|
|
2053
|
+
const identity = relayIdentityOf(relay)
|
|
2054
|
+
if (identity === undefined || relay.state !== 'online') return
|
|
2055
|
+
statusSyncInFlight = true
|
|
2056
|
+
// The race guard arms HERE: only frames arriving after this point
|
|
2057
|
+
// outrank the snapshot (anything older predates the fetch and is stale
|
|
2058
|
+
// relative to it).
|
|
2059
|
+
liveStatusSeq.clear()
|
|
2060
|
+
const startSeq = ++statusSeq
|
|
2061
|
+
// The wire shape is the host descriptor's (see runSummarySync — one
|
|
2062
|
+
// strict `{ _request: {} }` parameter).
|
|
2063
|
+
void relay.invoke('session', 'list', { _request: {} }, undefined).then(
|
|
2064
|
+
(value) => {
|
|
2065
|
+
statusSyncInFlight = false
|
|
2066
|
+
if (!alive) return
|
|
2067
|
+
const rows = isPlainObject(value) && Array.isArray(value.items) ? value.items : []
|
|
2068
|
+
for (const row of rows) {
|
|
2069
|
+
if (!isPlainObject(row) || typeof row.sessionId !== 'string' || row.sessionId === '') continue
|
|
2070
|
+
// A real-time frame that landed while this fetch ran is fresher
|
|
2071
|
+
// than the snapshot — never overwrite it (T75 race guard).
|
|
2072
|
+
if ((liveStatusSeq.get(row.sessionId) ?? 0) > startSeq) continue
|
|
2073
|
+
channel.push({
|
|
2074
|
+
type: 'emit',
|
|
2075
|
+
event: 'api-session/status',
|
|
2076
|
+
args: [toVirtual(identity.serverId, row.sessionId), row.running === true],
|
|
2077
|
+
})
|
|
2078
|
+
}
|
|
2079
|
+
// The serving period that queued mid-fetch is still unserved by THIS
|
|
2080
|
+
// round (its snapshot predates it) — its own round runs now.
|
|
2081
|
+
if (pendingStatusSync) {
|
|
2082
|
+
pendingStatusSync = false
|
|
2083
|
+
runStatusSync()
|
|
2084
|
+
}
|
|
2085
|
+
},
|
|
2086
|
+
(error: unknown) => {
|
|
2087
|
+
statusSyncInFlight = false
|
|
2088
|
+
if (!alive) return
|
|
2089
|
+
recordFailure('session/list', error instanceof RelayError ? error.code : 'internal')
|
|
2090
|
+
// The queued serving period survives a failed fetch: it is still
|
|
2091
|
+
// unserved, and one retry now beats waiting for the next period
|
|
2092
|
+
// (bounded — no fetch runs while the relay is offline).
|
|
2093
|
+
if (pendingStatusSync) {
|
|
2094
|
+
pendingStatusSync = false
|
|
2095
|
+
runStatusSync()
|
|
2096
|
+
}
|
|
2097
|
+
},
|
|
2098
|
+
)
|
|
2099
|
+
}
|
|
1972
2100
|
const noteOnlineIdentity = (identity: MergerIdentity, firstOnline: boolean): void => {
|
|
1973
2101
|
const key = `${identity.serverId}\u0000${identity.serverName}`
|
|
1974
2102
|
if (!firstOnline && key === refreshKey) return
|
|
@@ -1986,6 +2114,10 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
|
|
|
1986
2114
|
clock.clearTimeout(retryTimer)
|
|
1987
2115
|
retryTimer = undefined
|
|
1988
2116
|
}
|
|
2117
|
+
// T75: a new serving period re-syncs RUNNING state too — the flips
|
|
2118
|
+
// that happened inside the offline window never traveled, so every
|
|
2119
|
+
// row's running flag is re-pushed from a fresh snapshot.
|
|
2120
|
+
runStatusSync()
|
|
1989
2121
|
}
|
|
1990
2122
|
// T63: a NEW serving period is the page-load hole closing — the UI's
|
|
1991
2123
|
// one list pull already ran (relay offline), so announce the rows now.
|
|
@@ -2079,6 +2211,12 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
|
|
|
2079
2211
|
deferredSummarySync = false
|
|
2080
2212
|
runSummarySync(pendingBatch)
|
|
2081
2213
|
}
|
|
2214
|
+
// T75: same rule for the status sync — a serving period that began
|
|
2215
|
+
// before the ready runs its re-sync now, never before the ready.
|
|
2216
|
+
if (deferredStatusSync) {
|
|
2217
|
+
deferredStatusSync = false
|
|
2218
|
+
runStatusSync()
|
|
2219
|
+
}
|
|
2082
2220
|
}
|
|
2083
2221
|
}
|
|
2084
2222
|
} catch (error) {
|
|
@@ -2127,6 +2265,16 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
|
|
|
2127
2265
|
}
|
|
2128
2266
|
const rewritten = rewriteRemoteEventFrame(frame, identity.serverId)
|
|
2129
2267
|
if (rewritten === null) continue
|
|
2268
|
+
// T75: a REAL-TIME status frame outranks any list snapshot a
|
|
2269
|
+
// status sync may still be fetching — record it (keyed by the
|
|
2270
|
+
// ORIGINAL id, the same ids the list rows carry) so the sync's
|
|
2271
|
+
// answer cannot overwrite it with stale data. Status frames
|
|
2272
|
+
// carry no eventId, so the orphan-close bookkeeping below is
|
|
2273
|
+
// untouched.
|
|
2274
|
+
if (isPlainObject(frame) && frame.type === 'emit' && frame.event === 'api-session/status') {
|
|
2275
|
+
const rowId = Array.isArray(frame.args) ? frame.args[0] : undefined
|
|
2276
|
+
if (typeof rowId === 'string' && rowId !== '') liveStatusSeq.set(rowId, ++statusSeq)
|
|
2277
|
+
}
|
|
2130
2278
|
// The orphan-close bookkeeping rides the two id-carrying frame
|
|
2131
2279
|
// types: waterfalls enter the record (once per leg — a repeat
|
|
2132
2280
|
// is not re-shown), cancels leave it.
|
package/src/merge-streams.ts
CHANGED
|
@@ -142,7 +142,7 @@
|
|
|
142
142
|
* `zr~<serverId>~provider/model` fallback string (the pre-fix2 display).
|
|
143
143
|
*/
|
|
144
144
|
|
|
145
|
-
import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js'
|
|
145
|
+
import { fromVirtual, isVirtual, toVirtual, VIRTUAL_GROUP_NAME_MARK } from './virtual-id.js'
|
|
146
146
|
|
|
147
147
|
/** The (serverId, serverName) pair a merger virtualizes with. A re-handshake
|
|
148
148
|
* with a different server retargets the SAME merger ({@link retarget}) so the
|
|
@@ -1119,8 +1119,13 @@ export function mergeSessionList(localResult: unknown, remoteResult: unknown, se
|
|
|
1119
1119
|
* order, RT dsh-client-ui-model-selection lib/client.js:510 — appended
|
|
1120
1120
|
* groups render last); each group id becomes the virtual group id
|
|
1121
1121
|
* `zr~<serverId>~<original>` and its name is prefixed
|
|
1122
|
-
* `${serverName} · ${name}` — the same workspace
|
|
1123
|
-
* merger uses
|
|
1122
|
+
* `${VIRTUAL_GROUP_NAME_MARK}${serverName} · ${name}` — the same workspace
|
|
1123
|
+
* title discipline the merger uses, PLUS the invisible WORD JOINER (T74):
|
|
1124
|
+
* the model menu's groups stopped carrying their id in the markup (0.2.0's
|
|
1125
|
+
* MenuGroup renders `aria-labelledby={useId()}` only), so the browser
|
|
1126
|
+
* classifies menu groups by this heading prefix instead
|
|
1127
|
+
* (client-data/model-group-side.ts). The sidebar's workspace TITLES stay
|
|
1128
|
+
* unmarked — those groups are keyed by their `zr~` row ids;
|
|
1124
1129
|
* - `default` and `failures` stay LOCAL untouched: the default drives what a
|
|
1125
1130
|
* blank LOCAL session would run (a remote session's current model comes
|
|
1126
1131
|
* from its projection instead), and the UI renders every failure as a
|
|
@@ -1159,7 +1164,9 @@ export function mergeModelCatalogs(localValue: unknown, remoteValue: unknown, id
|
|
|
1159
1164
|
virtualGroups.push({
|
|
1160
1165
|
...group,
|
|
1161
1166
|
id,
|
|
1162
|
-
...(typeof group.name === 'string'
|
|
1167
|
+
...(typeof group.name === 'string'
|
|
1168
|
+
? { name: `${VIRTUAL_GROUP_NAME_MARK}${identity.serverName} · ${group.name}` }
|
|
1169
|
+
: {}),
|
|
1163
1170
|
})
|
|
1164
1171
|
}
|
|
1165
1172
|
if (virtualGroups.length === 0) return localValue
|
package/src/relay-access.ts
CHANGED
|
@@ -176,9 +176,12 @@ interface RelayMethod {
|
|
|
176
176
|
resultFilter?: InvokeFilter
|
|
177
177
|
/** The one EVENT-FORWARDING entry (T32, `$zr/events`): the stream route
|
|
178
178
|
* opens the gateway's `$events` wire stream (never `gw.stream`, spike §2.1
|
|
179
|
-
* 坑 1) and filters frames by the
|
|
180
|
-
*
|
|
181
|
-
*
|
|
179
|
+
* 坑 1) and filters frames per frame — waterfalls by the `agentId`, and
|
|
180
|
+
* since T75 the one forwardable emit (`api-session/status`) by the
|
|
181
|
+
* session id in `args[0]`; every other emit is server-wide state and is
|
|
182
|
+
* dropped. The answer route matches `eventId`s against what was
|
|
183
|
+
* forwarded. Like the global reads it claims no session field — access is
|
|
184
|
+
* judged per frame / per answer. */
|
|
182
185
|
events?: true
|
|
183
186
|
}
|
|
184
187
|
|
package/src/relay-server.ts
CHANGED
|
@@ -399,6 +399,21 @@ interface ForwardableCancelFrame extends Record<string, unknown> {
|
|
|
399
399
|
eventId: string
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
+
/**
|
|
403
|
+
* T75: the ONE emit shape the relay forwards — a single session's
|
|
404
|
+
* running-state flip (`api-session/status`), exactly what the host emits on
|
|
405
|
+
* `agent/status` (RT dsh-api-session-controller lib/index.js:2884-2886:
|
|
406
|
+
* `ctx.emit("api-session/status", agent.id, status === "running")`), carried
|
|
407
|
+
* on the wire as the client face's exact-keys emit with a JSON-array `args`
|
|
408
|
+
* (RT dsh-api-gateway lib/index.js:846-853 builds `{type, event, args}`;
|
|
409
|
+
* lib/client.js `parseRemoteEventFrame` demands that shape back).
|
|
410
|
+
*/
|
|
411
|
+
interface ForwardableStatusEmitFrame extends Record<string, unknown> {
|
|
412
|
+
type: 'emit'
|
|
413
|
+
event: 'api-session/status'
|
|
414
|
+
args: [string, boolean]
|
|
415
|
+
}
|
|
416
|
+
|
|
402
417
|
function isForwardableWaterfall(frame: Record<string, unknown>): frame is ForwardableWaterfallFrame {
|
|
403
418
|
return (
|
|
404
419
|
Reflect.ownKeys(frame).length === 5 &&
|
|
@@ -421,6 +436,18 @@ function isForwardableCancel(frame: Record<string, unknown>): frame is Forwardab
|
|
|
421
436
|
)
|
|
422
437
|
}
|
|
423
438
|
|
|
439
|
+
function isForwardableStatusEmit(frame: Record<string, unknown>): frame is ForwardableStatusEmitFrame {
|
|
440
|
+
return (
|
|
441
|
+
Reflect.ownKeys(frame).length === 3 &&
|
|
442
|
+
['type', 'event', 'args'].every((key) => Object.hasOwn(frame, key)) &&
|
|
443
|
+
frame.event === 'api-session/status' &&
|
|
444
|
+
Array.isArray(frame.args) &&
|
|
445
|
+
frame.args.length === 2 &&
|
|
446
|
+
isEventId(frame.args[0]) &&
|
|
447
|
+
typeof frame.args[1] === 'boolean'
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
|
|
424
451
|
/**
|
|
425
452
|
* Constant-time secret comparison. Different lengths are trivially unequal
|
|
426
453
|
* (timingSafeEqual itself throws on that); an empty configured secret refuses
|
|
@@ -867,17 +894,22 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
867
894
|
* - CANCEL frames are forwarded only for events this subscription
|
|
868
895
|
* forwarded (an unshared session's activity timeline must not leak
|
|
869
896
|
* through cancels), and the entry goes with the forward.
|
|
870
|
-
* - EMIT frames are dropped
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
897
|
+
* - EMIT frames are dropped, with ONE exception (T75): a
|
|
898
|
+
* `api-session/status` emit whose `args[0]` names an accessible session
|
|
899
|
+
* crosses as a fresh `{type, event, args:[id, running]}` — it is the
|
|
900
|
+
* only share-SCOPED emit the host produces (one session's running flip,
|
|
901
|
+
* judged per frame by `isAccessible` like a waterfall's `agentId`).
|
|
902
|
+
* Every other emit is server-wide state (`api-session/added` summaries
|
|
903
|
+
* and titles, account expirations, cordis chatter) that is not
|
|
904
|
+
* share-scoped: forwarding it would leak unshared sessions and feed the
|
|
905
|
+
* sub-client ids it would treat as local. Everything else a sub-client
|
|
906
|
+
* needs about a session's state travels the workspace/control streams,
|
|
907
|
+
* which ARE share-filtered.
|
|
908
|
+
* - Anything not shaped exactly like a forwardable waterfall, cancel, or
|
|
909
|
+
* status emit (T32-fix2, mirroring the client face's
|
|
910
|
+
* `parseRemoteEventFrame` exact-keys validation) is dropped too: a frame
|
|
911
|
+
* that slipped through would fail the sub-client's whole `$events`
|
|
912
|
+
* generation and leave it failing and reconnecting in a loop.
|
|
881
913
|
*
|
|
882
914
|
* DROPPED DELIVERIES STAY PENDING (T32-fix2): a dropped waterfall used to
|
|
883
915
|
* be abstained `next` on behalf. That is gone on purpose. DSH's `next`
|
|
@@ -917,6 +949,12 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
|
|
|
917
949
|
sub.events.delete(frame.eventId)
|
|
918
950
|
return { ...frame, eventId: `${sub.token}.${frame.eventId}` }
|
|
919
951
|
}
|
|
952
|
+
if (frame.type === 'emit') {
|
|
953
|
+
// T75: the one share-scoped emit. A NEW frame with a fresh args array
|
|
954
|
+
// leaves — never the host's object itself.
|
|
955
|
+
if (!isForwardableStatusEmit(frame) || !isAccessible(frame.args[0])) return null
|
|
956
|
+
return { type: 'emit', event: frame.event, args: [frame.args[0], frame.args[1]] }
|
|
957
|
+
}
|
|
920
958
|
return null
|
|
921
959
|
}
|
|
922
960
|
|
package/src/virtual-id.ts
CHANGED
|
@@ -20,6 +20,21 @@
|
|
|
20
20
|
/** The fixed prefix every virtual id starts with. */
|
|
21
21
|
export const VIRTUAL_ID_PREFIX = 'zr~'
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The invisible WORD JOINER (U+2060) every virtual GROUP NAME starts with
|
|
25
|
+
* (T74). DSH 0.2.0 renders the model menu's provider groups with the
|
|
26
|
+
* primitives' MenuGroup, whose `aria-labelledby` carries only a useId — the
|
|
27
|
+
* group id is gone from the markup — so the browser half can no longer tell
|
|
28
|
+
* a virtual group from a local one by attribute. The merged catalog's group
|
|
29
|
+
* NAME (merge-streams.ts mergeModelCatalogs) is the one mark that survives
|
|
30
|
+
* in both 0.1.7 and 0.2.0, and effects/model-group-side.ts classifies each
|
|
31
|
+
* group container by which headings start with it. Zero-width: headings
|
|
32
|
+
* read exactly as before. Sidebar workspace TITLES deliberately do NOT
|
|
33
|
+
* carry it — those groups are told apart by their `zr~` row ids, not by
|
|
34
|
+
* text.
|
|
35
|
+
*/
|
|
36
|
+
export const VIRTUAL_GROUP_NAME_MARK = '\u2060'
|
|
37
|
+
|
|
23
38
|
/** The server id half: exactly the 8 lowercase hex chars relay-server.ts
|
|
24
39
|
* mints (`randomBytes(4).toString('hex')`). */
|
|
25
40
|
const SERVER_ID_PATTERN = /^[0-9a-f]{8}$/
|