dsh-zen-remote 1.1.16 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +54 -9
- package/README.md +249 -190
- package/README.zh-CN.md +247 -201
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +12 -11
- package/cordis.patch.yml.example +24 -9
- package/docs/interface.md +57 -2
- package/docs/remote-access.en.md +99 -58
- package/docs/remote-access.md +99 -42
- package/dsh-push.mjs +67 -54
- package/lan-gate.mjs +88 -20
- package/lib/activity.js +168 -0
- package/lib/activity.js.map +1 -0
- package/lib/admin-routes.js +526 -0
- package/lib/admin-routes.js.map +1 -0
- package/lib/client-pairing.js +224 -0
- package/lib/client-pairing.js.map +1 -0
- package/lib/client-routes.js +674 -0
- package/lib/client-routes.js.map +1 -0
- package/lib/client.js +10051 -4777
- package/lib/config.js +346 -0
- package/lib/config.js.map +1 -0
- package/lib/fetch-route-intercept.js +418 -0
- package/lib/fetch-route-intercept.js.map +1 -0
- package/lib/fingerprint.js +363 -0
- package/lib/fingerprint.js.map +1 -0
- package/lib/http.js +69 -0
- package/lib/http.js.map +1 -0
- package/lib/index.js +675 -54
- package/lib/index.js.map +1 -1
- package/lib/intercept-shape.js +81 -0
- package/lib/intercept-shape.js.map +1 -0
- package/lib/intercept.js +3040 -0
- package/lib/intercept.js.map +1 -0
- package/lib/lan-gate-server.cjs +532 -121
- package/lib/merge-streams.js +1165 -0
- package/lib/merge-streams.js.map +1 -0
- package/lib/relay-access.js +496 -0
- package/lib/relay-access.js.map +1 -0
- package/lib/relay-client.js +1303 -0
- package/lib/relay-client.js.map +1 -0
- package/lib/relay-filter.js +282 -0
- package/lib/relay-filter.js.map +1 -0
- package/lib/relay-server.js +1792 -0
- package/lib/relay-server.js.map +1 -0
- package/lib/restart-fields.js +34 -0
- package/lib/restart-fields.js.map +1 -0
- package/lib/restart-watcher.js +106 -0
- package/lib/restart-watcher.js.map +1 -0
- package/lib/session-reference.js +157 -0
- package/lib/session-reference.js.map +1 -0
- package/lib/share-export.js +16 -1
- package/lib/share-export.js.map +1 -1
- package/lib/share-ops.js +86 -0
- package/lib/share-ops.js.map +1 -0
- package/lib/share-store.js +294 -0
- package/lib/share-store.js.map +1 -0
- package/lib/types/activity.d.ts +99 -0
- package/lib/types/activity.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +129 -0
- package/lib/types/admin-routes.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileHomeChips.d.ts +1 -1
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts +13 -2
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts +20 -3
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
- package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
- package/lib/types/client/RemoteShareMenu.d.ts +28 -0
- package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
- package/lib/types/client/compat/desktop.d.ts +21 -0
- package/lib/types/client/compat/desktop.d.ts.map +1 -0
- package/lib/types/client/compat/types.d.ts +57 -0
- package/lib/types/client/compat/types.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +37 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
- package/lib/types/client/effects/permission-sheet.d.ts +39 -0
- package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
- package/lib/types/client/effects/remote-session.d.ts +25 -0
- package/lib/types/client/effects/remote-session.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +9 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +187 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote-fetch.d.ts +51 -0
- package/lib/types/client/remote-fetch.d.ts.map +1 -0
- package/lib/types/client/remote-group-expansion.d.ts +41 -0
- package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client/remote-share-css.d.ts +19 -0
- package/lib/types/client/remote-share-css.d.ts.map +1 -0
- package/lib/types/client/remote-share-register.d.ts +113 -0
- package/lib/types/client/remote-share-register.d.ts.map +1 -0
- package/lib/types/client/remote-status-css.d.ts +15 -0
- package/lib/types/client/remote-status-css.d.ts.map +1 -0
- package/lib/types/client/remote-status-register.d.ts +46 -0
- package/lib/types/client/remote-status-register.d.ts.map +1 -0
- package/lib/types/client/settings/SettingsSection.d.ts +46 -0
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
- package/lib/types/client/settings/register-settings.d.ts +58 -0
- package/lib/types/client/settings/register-settings.d.ts.map +1 -0
- package/lib/types/client/settings/settings-css.d.ts +10 -0
- package/lib/types/client/settings/settings-css.d.ts.map +1 -0
- package/lib/types/client/share/share-preview.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/info.css.d.ts +1 -1
- package/lib/types/client/styles/info.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +2 -0
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
- package/lib/types/client-data/current-session.d.ts +41 -0
- package/lib/types/client-data/current-session.d.ts.map +1 -0
- package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
- package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +148 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +774 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -0
- package/lib/types/client-data/shares.d.ts +211 -0
- package/lib/types/client-data/shares.d.ts.map +1 -0
- package/lib/types/client-pairing.d.ts +114 -0
- package/lib/types/client-pairing.d.ts.map +1 -0
- package/lib/types/client-routes.d.ts +112 -0
- package/lib/types/client-routes.d.ts.map +1 -0
- package/lib/types/config.d.ts +200 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +156 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -0
- package/lib/types/fingerprint.d.ts +164 -0
- package/lib/types/fingerprint.d.ts.map +1 -0
- package/lib/types/http.d.ts +41 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/index.d.ts +56 -24
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept-shape.d.ts +32 -0
- package/lib/types/intercept-shape.d.ts.map +1 -0
- package/lib/types/intercept.d.ts +432 -0
- package/lib/types/intercept.d.ts.map +1 -0
- package/lib/types/merge-streams.d.ts +393 -0
- package/lib/types/merge-streams.d.ts.map +1 -0
- package/lib/types/relay-access.d.ts +310 -0
- package/lib/types/relay-access.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +274 -0
- package/lib/types/relay-client.d.ts.map +1 -0
- package/lib/types/relay-filter.d.ts +103 -0
- package/lib/types/relay-filter.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +208 -0
- package/lib/types/relay-server.d.ts.map +1 -0
- package/lib/types/restart-fields.d.ts +22 -0
- package/lib/types/restart-fields.d.ts.map +1 -0
- package/lib/types/restart-watcher.d.ts +83 -0
- package/lib/types/restart-watcher.d.ts.map +1 -0
- package/lib/types/session-reference.d.ts +66 -0
- package/lib/types/session-reference.d.ts.map +1 -0
- package/lib/types/share-export.d.ts +14 -1
- package/lib/types/share-export.d.ts.map +1 -1
- package/lib/types/share-ops.d.ts +79 -0
- package/lib/types/share-ops.d.ts.map +1 -0
- package/lib/types/share-store.d.ts +87 -0
- package/lib/types/share-store.d.ts.map +1 -0
- package/lib/types/virtual-id.d.ts +38 -0
- package/lib/types/virtual-id.d.ts.map +1 -0
- package/lib/virtual-id.js +52 -0
- package/lib/virtual-id.js.map +1 -0
- package/llms.txt +17 -14
- package/package.json +60 -50
- package/scripts/build-client.mjs +5 -3
- package/scripts/check-host-shadow.mjs +19 -2
- package/scripts/check-settings-form.mjs +933 -0
- package/scripts/check-shares.mjs +397 -0
- package/scripts/check-upload-endpoint.mjs +59 -3
- package/scripts/sync-doc-version.mjs +8 -15
- package/scripts/verify-fingerprint-live.mjs +111 -0
- package/src/activity.ts +218 -0
- package/src/admin-routes.ts +627 -0
- package/src/client/MobileAttachButton.tsx +2 -2
- package/src/client/MobileAttachChips.tsx +3 -3
- package/src/client/MobileDrawerFooter.tsx +7 -4
- package/src/client/MobileHome.tsx +20 -16
- package/src/client/MobileHomeChips.tsx +16 -16
- package/src/client/MobileNavOverlay.tsx +2 -2
- package/src/client/MobileNavToggle.tsx +3 -3
- package/src/client/MobileSessionHeader.tsx +63 -42
- package/src/client/MobileSessionInfo.tsx +118 -17
- package/src/client/RemoteComposerBanner.tsx +76 -0
- package/src/client/RemoteHeaderIcon.tsx +103 -0
- package/src/client/RemoteShareMenu.tsx +57 -0
- package/src/client/RemoteStatusIcon.tsx +91 -0
- package/src/client/compat/desktop.ts +22 -0
- package/src/client/compat/types.ts +62 -0
- package/src/client/current-session-report.ts +164 -0
- package/src/client/effects/header-status.ts +21 -17
- package/src/client/effects/keyboard-guard.ts +65 -1
- package/src/client/effects/permission-sheet.ts +149 -0
- package/src/client/effects/remote-session.ts +68 -0
- package/src/client/effects/turn-fold.ts +52 -0
- package/src/client/index.tsx +151 -36
- package/src/client/locales.ts +396 -2
- package/src/client/remote-fetch.ts +125 -0
- package/src/client/remote-group-expansion.ts +190 -0
- package/src/client/remote-share-css.ts +54 -0
- package/src/client/remote-share-register.ts +244 -0
- package/src/client/remote-status-css.ts +64 -0
- package/src/client/remote-status-register.ts +107 -0
- package/src/client/settings/SettingsSection.tsx +1229 -0
- package/src/client/settings/register-settings.ts +116 -0
- package/src/client/settings/settings-css.ts +236 -0
- package/src/client/share/share-preview.tsx +6 -3
- package/src/client/share/share-sheet.tsx +3 -3
- package/src/client/styles/composer.css.ts +119 -26
- package/src/client/styles/header.css.ts +7 -7
- package/src/client/styles/info.css.ts +74 -0
- package/src/client/styles/remote-session.css.ts +251 -0
- package/src/client-data/current-session.ts +57 -0
- package/src/client-data/remote-group-expansion.ts +145 -0
- package/src/client-data/remote-status.ts +374 -0
- package/src/client-data/settings-form.ts +1371 -0
- package/src/client-data/shares.ts +467 -0
- package/src/client-pairing.ts +244 -0
- package/src/client-routes.ts +732 -0
- package/src/config.ts +447 -0
- package/src/fetch-route-intercept.ts +510 -0
- package/src/fingerprint.ts +408 -0
- package/src/http.ts +68 -0
- package/src/index.ts +719 -62
- package/src/intercept-shape.ts +87 -0
- package/src/intercept.ts +3362 -0
- package/src/merge-streams.ts +1272 -0
- package/src/relay-access.ts +669 -0
- package/src/relay-client.ts +1514 -0
- package/src/relay-filter.ts +291 -0
- package/src/relay-server.ts +2025 -0
- package/src/restart-fields.ts +36 -0
- package/src/restart-watcher.ts +140 -0
- package/src/session-reference.ts +156 -0
- package/src/share-export.ts +26 -1
- package/src/share-ops.ts +114 -0
- package/src/share-store.ts +373 -0
- package/src/virtual-id.ts +59 -0
|
@@ -0,0 +1,1272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure mergers for the three GLOBAL calls (T23b-2): `workspace/follow` and
|
|
3
|
+
* `session/control` are STREAMS whose frames carry no session-locating
|
|
4
|
+
* argument, and `session/list` is an unscoped invoke — the sidebar reads all
|
|
5
|
+
* three to render its tree, so a sub-client must see the server's shared
|
|
6
|
+
* sessions merged INTO the local answer instead of in place of it.
|
|
7
|
+
*
|
|
8
|
+
* Frame and result shapes are the generated 0.2.0 codecs, field by field:
|
|
9
|
+
* `workspace_follow_result` (baseline / upsert / remove / order / archived /
|
|
10
|
+
* pinned), `session_control_result` (exactly two frame types: `baseline`
|
|
11
|
+
* nesting `value.projections`, and one-key `projection` updates), and
|
|
12
|
+
* `session_list_result` (`{ items }` — no cursor field). The same shapes the
|
|
13
|
+
* server filters by (relay-filter.ts); remote frames arriving here have
|
|
14
|
+
* ALREADY been narrowed to the shared sessions and carry ORIGINAL ids.
|
|
15
|
+
*
|
|
16
|
+
* The rules, mirroring tasks/T23b2.md as corrected by tasks/T23b2-fix.md:
|
|
17
|
+
* - local frames pass through, except that `baseline` / `order` get the
|
|
18
|
+
* remote workspaces APPENDED to their order (remote groups sort after
|
|
19
|
+
* local) and the `archived` / `pinned` lists merge local + remote;
|
|
20
|
+
* - a remote baseline NEVER travels as a second baseline — it would wipe the
|
|
21
|
+
* local data. It becomes one virtualized `upsert` per remote workspace,
|
|
22
|
+
* then the merged `order` / `archived` / `pinned`;
|
|
23
|
+
* - remote frames that arrive before the local baseline are CACHED as state
|
|
24
|
+
* only — the UI has seen nothing yet, so emitting them would order
|
|
25
|
+
* workspaces the local baseline would immediately erase;
|
|
26
|
+
* - a remote stream that died (or a merely offline relay) emits NOTHING and
|
|
27
|
+
* keeps the shown state ({@link WorkspaceMerger.onRemoteDown}): the UI's
|
|
28
|
+
* `ClientWorkspaceModel.remove()` records ids in a never-cleared blacklist
|
|
29
|
+
* and both `upsert` and baseline replacement drop blacklisted ids, so one
|
|
30
|
+
* `remove` for a virtual id would make that group un-revivable on
|
|
31
|
+
* reconnect — while the UI itself keeps the last complete projection
|
|
32
|
+
* visible across carrier loss (its `handleCarrierFailure`);
|
|
33
|
+
* - a NEW remote baseline on the same server is DIFFED against the shown
|
|
34
|
+
* set: still present → fresh `upsert` (content from the new baseline),
|
|
35
|
+
* absent (deleted server-side / no longer shared) → `remove`, then the
|
|
36
|
+
* merged `order` / `archived` / `pinned`;
|
|
37
|
+
* - a server RENAME (same serverId) re-upserts the shown groups under the
|
|
38
|
+
* new title — no removals ({@link WorkspaceMerger.onServerRenamed});
|
|
39
|
+
* - only a serverId CHANGE removes the whole group
|
|
40
|
+
* ({@link WorkspaceMerger.onRemoteGone}): the new prefix is fresh, so it
|
|
41
|
+
* can never collide with the UI's blacklist. The relay entering
|
|
42
|
+
* `unpaired` / `revoked` is NOT this case (T23b2-fix3): the server
|
|
43
|
+
* persists its serverId (relay-server.ts loadServerId), so a re-pair to
|
|
44
|
+
* the same server reuses the prefix and is handled as a remote death
|
|
45
|
+
* ({@link WorkspaceMerger.onRemoteDown}) — the reconnect diff revives
|
|
46
|
+
* the group;
|
|
47
|
+
* - a relay status that is not serving normally (T34: offline, unpaired,
|
|
48
|
+
* revoked, interface mismatch) only ANNOTATES TITLES: setStatus +
|
|
49
|
+
* onStatusChanged re-upsert the shown groups under the annotated titles,
|
|
50
|
+
* and the reconnecting baseline (which always upserts) restores them.
|
|
51
|
+
* - a session the SERVER stopped serving (remote closed / idle-slept / its
|
|
52
|
+
* fork parent closed → the filtered workspace upsert drops it from
|
|
53
|
+
* `sessionIds`) keeps a TOMBSTONE in the group it was last seen in (CP4,
|
|
54
|
+
* reworked by CP4-client-fix2): the merger remembers the workspace each
|
|
55
|
+
* session id was last carried by, and the forwarded record for THAT
|
|
56
|
+
* workspace still lists the id (appended at the end of `sessionIds`). The
|
|
57
|
+
* UI's session store keeps the merged projection/list entry and nothing
|
|
58
|
+
* ever removes it, so without this the sidebar would park the session in
|
|
59
|
+
* 「未分组」 forever — and hiding it in the merged `archived` frame instead
|
|
60
|
+
* (what CP4-client first did) makes the RT navigation guard
|
|
61
|
+
* `clearArchivedCurrent` (dsh-client-ui-workspace `watchNavigation`) kick
|
|
62
|
+
* the OPEN session page back to the home page before its 「远程已关闭」
|
|
63
|
+
* banner can show. A re-share (the id back in some workspace's
|
|
64
|
+
* `sessionIds`) renders it live again and clears the tombstone; the
|
|
65
|
+
* tombstone dies with its workspace (a remove) and with the identity
|
|
66
|
+
* (onRemoteGone).
|
|
67
|
+
* - T58 reworks the tombstone's VISIBILITY, reshaped by T65: a closed-remote
|
|
68
|
+
* session is HIDDEN — moved into the merged `archived` set (the host's
|
|
69
|
+
* `sessionVisible` hides archived ids under the default filter) — while
|
|
70
|
+
* STAYING in its group's forwarded `sessionIds`: under「显示已归档」it
|
|
71
|
+
* renders in place, and it can never resurface as a 「未分组」 stray. The
|
|
72
|
+
* one exception is the session the user currently has OPEN: it is not
|
|
73
|
+
* hidden at all (the page must keep its 「远程已关闭」 banner), and once
|
|
74
|
+
* the caller reports a different current session
|
|
75
|
+
* ({@link WorkspaceMerger.setCurrentSession}) it moves into `archived`
|
|
76
|
+
* too. `archived` is exactly what the host's navigation guard
|
|
77
|
+
* `clearArchivedCurrent` acts on, so the CURRENT session may never enter
|
|
78
|
+
* it — and an unreadable current-session signal falls back to the
|
|
79
|
+
* conservative tombstone behavior (nothing is hidden). Because the
|
|
80
|
+
* browser's report can lag the user's switch by up to a poll interval, a
|
|
81
|
+
* JUST-created tombstone is not hidden for a short grace (T65, injectable
|
|
82
|
+
* clock): the 1s poll re-judges with the fresh report afterwards — the
|
|
83
|
+
* session the user really switched to keeps its tombstone, anything else
|
|
84
|
+
* hides. Re-sharing the session takes it back out of `archived`
|
|
85
|
+
* (live-carried ids never count as hidden). Since hiding moves only the
|
|
86
|
+
* archived set, hide/reveal updates are exactly one merged archived frame
|
|
87
|
+
* — no group content changes, no ordering dance; the current session
|
|
88
|
+
* never enters the set by construction. The hidden ids are sticky: they
|
|
89
|
+
* survive even the death of their home workspace until a re-share or the
|
|
90
|
+
* identity ends.
|
|
91
|
+
* - a workspace with NOTHING to show is not shown at all (T56): the server
|
|
92
|
+
* keeps every workspace and only narrows `sessionIds` (relay-filter.ts),
|
|
93
|
+
* so a workspace where nothing is shared would arrive as an empty group
|
|
94
|
+
* and render as a bare 「服务端名 · 工作区名」 heading. A workspace the UI
|
|
95
|
+
* has never seen is therefore held back — no baseline item, no upsert, no
|
|
96
|
+
* order entry — until its forwarded `sessionIds` (live sessions or
|
|
97
|
+
* tombstones it must carry) first gains content. From that first forward
|
|
98
|
+
* on it counts as SHOWN and stays shown even when the sessions leave
|
|
99
|
+
* again: a `remove` would blacklist the id in the UI's ClientWorkspaceModel
|
|
100
|
+
* and make the group un-revivable, so an emptied group keeps its (empty)
|
|
101
|
+
* display until the page reload rebuilds the model and this rule hides it
|
|
102
|
+
* again. Every removal path — the reconnecting baseline diff, an explicit
|
|
103
|
+
* server remove, {@link WorkspaceMerger.onRemoteGone} — emits a `remove`
|
|
104
|
+
* only for shown workspaces; never-shown ones leave the state silently.
|
|
105
|
+
*
|
|
106
|
+
* Two KNOWN LIMITATIONS, both rooted in the UI's `removedIds` blacklist
|
|
107
|
+
* never clearing during a page's life (a reload rebuilds the model from
|
|
108
|
+
* scratch, which is why a reload fixes both):
|
|
109
|
+
* - switch to a DIFFERENT server and later back to the SAME old one: the
|
|
110
|
+
* switch-away removed the old prefix, so the returning group's upserts
|
|
111
|
+
* are dropped by the blacklist and it stays invisible until the page is
|
|
112
|
+
* reloaded;
|
|
113
|
+
* - the server deletes a workspace and later shares a NEW workspace under
|
|
114
|
+
* the SAME id: the deletion's remove blacklists the id, and the new
|
|
115
|
+
* workspace's upserts are dropped the same way.
|
|
116
|
+
*
|
|
117
|
+
* Everything here is a pure state machine: no DSH imports, no network, no
|
|
118
|
+
* clocks — the virtual-id arithmetic is the one dependency (virtual-id.ts).
|
|
119
|
+
* Shapes are read defensively like relay-filter.ts does: a malformed frame is
|
|
120
|
+
* dropped (diagnosed through the optional hook), never forwarded.
|
|
121
|
+
*
|
|
122
|
+
* T52 adds two RESULT-side pure helpers on the same principles:
|
|
123
|
+
* {@link mergeModelCatalogs} folds the relay's `session/modelCatalog` answer
|
|
124
|
+
* into the local one (the catalog is the model-selection dropdown's data), and
|
|
125
|
+
* {@link virtualizeModelSelectionValue} rewrites a `modelSelection` projection
|
|
126
|
+
* value's provider ids to virtual group ids so the UI can find the current
|
|
127
|
+
* model's display name in the merged catalog.
|
|
128
|
+
*
|
|
129
|
+
* T52-fix2 made that rewrite CONDITIONAL on the server catalog listing the
|
|
130
|
+
* provider; T52-fix3 reverts it to UNCONDITIONAL, and the revert is final:
|
|
131
|
+
* the host's projection store is FIRST-WRITE-WINS per sequence number
|
|
132
|
+
* (dsh-api-session-controller lib/client.js:986-994 —
|
|
133
|
+
* `if (row?.kind === "sequenced" && seq <= row.seq) return`, and the
|
|
134
|
+
* seed / list blocks take the same road). A projection rewritten ORIGINAL
|
|
135
|
+
* while the catalog had not landed yet occupies its seq; when the catalog
|
|
136
|
+
* arrives, the same seq's VIRTUAL value is dropped as stale — the server
|
|
137
|
+
* group never gets its check mark, and resubmitting a reasoning effort
|
|
138
|
+
* echoes the ORIGINAL provider back, which `session/selectModel`'s routing
|
|
139
|
+
* refuses ("请选择「服务端 · …」分组里的模型"). The rewrite of ONE provider
|
|
140
|
+
* must therefore not depend on when the catalog happened to arrive. The
|
|
141
|
+
* accepted cost: a provider absent from the server's OWN catalog renders the
|
|
142
|
+
* `zr~<serverId>~provider/model` fallback string (the pre-fix2 display).
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js'
|
|
146
|
+
|
|
147
|
+
/** The (serverId, serverName) pair a merger virtualizes with. A re-handshake
|
|
148
|
+
* with a different server retargets the SAME merger ({@link retarget}) so the
|
|
149
|
+
* locally observed baseline/order state survives the swap. */
|
|
150
|
+
export interface MergerIdentity {
|
|
151
|
+
serverId: string
|
|
152
|
+
serverName: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The status annotation (T34) appended to every shown remote group's title
|
|
157
|
+
* while the relay is not serving normally. The CALLER (intercept.ts) maps the
|
|
158
|
+
* relay state onto one annotation — `revoked` / `unpaired` outrank `offline`,
|
|
159
|
+
* which outranks `mismatch` — and the merger only renders the suffix it is
|
|
160
|
+
* told to. The suffixes are the CHINESE copy, deliberately hardcoded here:
|
|
161
|
+
* this module is background code with no access to the UI language (the
|
|
162
|
+
* English forms live in src/client/locales.ts, `remoteGroup*` keys).
|
|
163
|
+
*/
|
|
164
|
+
export type MergerAnnotation = 'none' | 'offline' | 'revoked' | 'unpaired' | 'mismatch'
|
|
165
|
+
|
|
166
|
+
const ANNOTATION_SUFFIX: Record<Exclude<MergerAnnotation, 'none'>, string> = {
|
|
167
|
+
offline: '(离线)',
|
|
168
|
+
revoked: '(令牌已吊销)',
|
|
169
|
+
unpaired: '(已解除配对)',
|
|
170
|
+
mismatch: '(版本有差异)',
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
174
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The string prefix of a wire list — a malformed frame degrades to []. */
|
|
178
|
+
function stringList(value: unknown): string[] {
|
|
179
|
+
if (!Array.isArray(value)) return []
|
|
180
|
+
return value.filter((item): item is string => typeof item === 'string')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The local workspace-id order of a baseline `value.items` / order frame. */
|
|
184
|
+
function idList(value: unknown): string[] {
|
|
185
|
+
if (!Array.isArray(value)) return []
|
|
186
|
+
const ids: string[] = []
|
|
187
|
+
for (const item of value) {
|
|
188
|
+
if (isPlainObject(item) && typeof item.workspaceId === 'string') ids.push(item.workspaceId)
|
|
189
|
+
else if (typeof item === 'string') ids.push(item)
|
|
190
|
+
}
|
|
191
|
+
return ids
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** One workspace record rewritten for the UI: ids virtualized, the title
|
|
195
|
+
* prefixed with the server name and — while an annotation is set — suffixed
|
|
196
|
+
* with its status copy. Values are copied — the caller's frame is never
|
|
197
|
+
* mutated. */
|
|
198
|
+
function virtualizeWorkspace(
|
|
199
|
+
record: Record<string, unknown>,
|
|
200
|
+
identity: MergerIdentity,
|
|
201
|
+
annotation: MergerAnnotation,
|
|
202
|
+
): Record<string, unknown> {
|
|
203
|
+
const virtualize = (id: string): string => toVirtual(identity.serverId, id)
|
|
204
|
+
const out: Record<string, unknown> = { ...record }
|
|
205
|
+
if (typeof record.workspaceId === 'string') out.workspaceId = virtualize(record.workspaceId)
|
|
206
|
+
if (typeof record.title === 'string') {
|
|
207
|
+
const suffix = annotation === 'none' ? '' : ANNOTATION_SUFFIX[annotation]
|
|
208
|
+
out.title = `${identity.serverName} · ${record.title}${suffix}`
|
|
209
|
+
}
|
|
210
|
+
if (Array.isArray(record.sessionIds)) {
|
|
211
|
+
out.sessionIds = record.sessionIds.map((id) => (typeof id === 'string' ? virtualize(id) : id))
|
|
212
|
+
}
|
|
213
|
+
return out
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface WorkspaceMergerOptions extends MergerIdentity {
|
|
217
|
+
/** Diagnostics for frames this merger dropped (unknown type or malformed).
|
|
218
|
+
* Optional: without it the drop is silent. */
|
|
219
|
+
onDiagnostic?: (message: string) => void
|
|
220
|
+
/** T65: the clock the tombstone hide-grace runs on. Default Date.now;
|
|
221
|
+
* tests advance an injectable fake to cross the grace. */
|
|
222
|
+
now?: () => number
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface WorkspaceMerger {
|
|
226
|
+
readonly serverId: string
|
|
227
|
+
readonly serverName: string
|
|
228
|
+
/** Feed one frame of the LOCAL workspace/follow stream; returns the frames
|
|
229
|
+
* the UI should see (usually exactly the input, order-merged when remote
|
|
230
|
+
* state exists). */
|
|
231
|
+
onLocal(frame: unknown): unknown[]
|
|
232
|
+
/** Feed one server-filtered frame of the REMOTE stream; returns the frames
|
|
233
|
+
* the UI should see (possibly none — state-only while the local baseline
|
|
234
|
+
* has not passed). A later baseline is DIFFED against what was shown. */
|
|
235
|
+
onRemote(frame: unknown): unknown[]
|
|
236
|
+
/** The remote stream died or the relay went offline (the same server is
|
|
237
|
+
* expected back): emits NOTHING and keeps the shown state — the UI keeps
|
|
238
|
+
* the last projection visible across carrier loss, and a `remove` here
|
|
239
|
+
* would blacklist the virtual ids against revival. */
|
|
240
|
+
onRemoteDown(): unknown[]
|
|
241
|
+
/** The remote side is gone FOR GOOD under THIS identity: a serverId
|
|
242
|
+
* change. (Since T23b2-fix3 an `unpaired` / `revoked` relay is NOT this
|
|
243
|
+
* case — the same serverId comes back on re-pair, so the caller uses
|
|
244
|
+
* {@link onRemoteDown} there.) Removes everything shown from the remote
|
|
245
|
+
* side, resets the remote state. Idempotent — a second call with no
|
|
246
|
+
* remote state returns []. */
|
|
247
|
+
onRemoteGone(): unknown[]
|
|
248
|
+
/** The server was renamed (same serverId — call after {@link retarget}):
|
|
249
|
+
* re-upserts every shown workspace under the new title, nothing else. */
|
|
250
|
+
onServerRenamed(): unknown[]
|
|
251
|
+
/** Record the status annotation (T34) future virtualized titles carry. No
|
|
252
|
+
* frames on its own — pair it with {@link onStatusChanged} to re-upsert the
|
|
253
|
+
* shown groups under the new annotation, or let the next natural upserts
|
|
254
|
+
* (a reconnecting baseline) carry it. */
|
|
255
|
+
setStatus(annotation: MergerAnnotation): void
|
|
256
|
+
/** Re-upsert every SHOWN workspace under the CURRENT annotation: the whole
|
|
257
|
+
* update when the relay's serving status changed without any remote frame
|
|
258
|
+
* (offline, revoked, unpaired, version mismatch). [] while nothing is
|
|
259
|
+
* shown (no local baseline yet, or no remote state). */
|
|
260
|
+
onStatusChanged(): unknown[]
|
|
261
|
+
/** Record the session id the UI currently has OPEN (T58) — the raw id as
|
|
262
|
+
* the UI knows it (a virtual id for a remote session, a local id
|
|
263
|
+
* otherwise); `undefined` means a READABLE signal says no session is
|
|
264
|
+
* open. An UNREADABLE signal is expressed by not calling this at all —
|
|
265
|
+
* the merger then keeps every tombstone (the conservative fallback).
|
|
266
|
+
* Returns the frames that apply the resulting hide transitions ([] when
|
|
267
|
+
* nothing changed). */
|
|
268
|
+
setCurrentSession(sessionId: string | undefined): unknown[]
|
|
269
|
+
/** Whether some closed-remote session is being kept visible — it is the
|
|
270
|
+
* current session (waiting for the user to navigate away), or the current
|
|
271
|
+
* session was never readable (conservative fallback). While true the
|
|
272
|
+
* caller should keep polling {@link setCurrentSession} at ≥1s intervals. */
|
|
273
|
+
readonly hasPendingHide: boolean
|
|
274
|
+
/** Point the merger at a (possibly different) server. Local state survives;
|
|
275
|
+
* for a serverId change the remote state must be gone first (onRemoteGone
|
|
276
|
+
* first); for a rename it may stay. */
|
|
277
|
+
retarget(identity: MergerIdentity): void
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The workspace/follow merger. Remote state is a Map keyed by the ORIGINAL
|
|
282
|
+
* workspace id whose INSERTION ORDER tracks the remote order (baseline sets
|
|
283
|
+
* it, upsert appends new keys, order frames reorder it) — that sequence is
|
|
284
|
+
* what gets appended to every local baseline/order that passes through.
|
|
285
|
+
*/
|
|
286
|
+
export function createWorkspaceMerger(options: WorkspaceMergerOptions): WorkspaceMerger {
|
|
287
|
+
const onDiagnostic = options.onDiagnostic
|
|
288
|
+
let identity: MergerIdentity = { serverId: options.serverId, serverName: options.serverName }
|
|
289
|
+
|
|
290
|
+
// The locally observed truth, updated from every local baseline / order /
|
|
291
|
+
// archived / pinned frame. Required to rebuild remote-free frames on
|
|
292
|
+
// onRemoteGone and to merge remote ids INTO local order frames.
|
|
293
|
+
let localSeen = false
|
|
294
|
+
let localOrder: string[] = []
|
|
295
|
+
let localArchived: string[] = []
|
|
296
|
+
let localPinned: string[] = []
|
|
297
|
+
|
|
298
|
+
// The remote truth in ORIGINAL ids; insertion order = remote order. A
|
|
299
|
+
// reconnecting baseline diffs its content against this; what the UI has
|
|
300
|
+
// ever been SHOWN is tracked beside it (the `shown` set, T56).
|
|
301
|
+
let remote = new Map<string, Record<string, unknown>>()
|
|
302
|
+
let remoteArchived: string[] = []
|
|
303
|
+
let remotePinned: string[] = []
|
|
304
|
+
// The tombstone registry (CP4-client-fix2): every session id any remote
|
|
305
|
+
// workspace has ever carried for this identity (original ids) → the
|
|
306
|
+
// original workspace id that LAST carried it. A session the server stopped
|
|
307
|
+
// serving (remote closed → the filtered workspace upsert drops it from
|
|
308
|
+
// `sessionIds`) keeps its UI entry, so the forwarded record of its last
|
|
309
|
+
// workspace still lists it. Entries die when the session is carried again
|
|
310
|
+
// (re-shared, anywhere — the home moves with it), when its workspace is
|
|
311
|
+
// removed, and with the identity (onRemoteGone).
|
|
312
|
+
let sessionHome = new Map<string, string>()
|
|
313
|
+
// T56: the workspace ids (originals) the UI has EVER been shown. The UI's
|
|
314
|
+
// ClientWorkspaceModel blacklists removed ids for the page's whole life, so
|
|
315
|
+
// only a shown workspace may ever receive a `remove` — and a workspace the
|
|
316
|
+
// UI never saw stays invisible while its forwarded sessionIds (live
|
|
317
|
+
// sessions + tombstones) is empty, instead of rendering as an empty
|
|
318
|
+
// 「服务端名 · 工作区名」 group. Once shown, a workspace stays shown even
|
|
319
|
+
// when its last session leaves (no remove — the blacklist would make the
|
|
320
|
+
// id un-revivable); the page reload rebuilds the model and the emptied
|
|
321
|
+
// group then never comes back.
|
|
322
|
+
const shown = new Set<string>()
|
|
323
|
+
// T58: the raw session id the UI currently has open, exactly as read from
|
|
324
|
+
// the host's persisted selection store — a virtual id for a remote
|
|
325
|
+
// session, a local id otherwise. The CALLER distinguishes an UNREADABLE
|
|
326
|
+
// signal (no usable storage) from a readable "no session is open" by not
|
|
327
|
+
// calling {@link setCurrentSession} at all in the former case — so
|
|
328
|
+
// `currentReadable` is what gates the conservative fallback, and
|
|
329
|
+
// `currentRaw === undefined` once readable means a real "nothing open".
|
|
330
|
+
// Devirtualized on use, so a rename needs no re-write and a foreign/local
|
|
331
|
+
// id naturally means "no remote session of ours is current".
|
|
332
|
+
let currentRaw: string | undefined = undefined
|
|
333
|
+
let currentReadable = false
|
|
334
|
+
// T58: the original session ids this merger moved into the merged
|
|
335
|
+
// `archived` set because their remote closed while they were not the
|
|
336
|
+
// current session. STICKY: entries survive even the death of their home
|
|
337
|
+
// workspace (a hidden session whose group is deleted must not resurface
|
|
338
|
+
// as a 「未分组」 stray) until the session is live-carried again (re-share)
|
|
339
|
+
// or the identity ends (onRemoteGone). Never contains the current
|
|
340
|
+
// session — the host's clearArchivedCurrent would kick its open page.
|
|
341
|
+
const hiddenSessions = new Set<string>()
|
|
342
|
+
// T65: when each tombstone was CREATED (the moment a close frame dropped a
|
|
343
|
+
// live id), for the hide grace below. Entries die with the tombstone: a
|
|
344
|
+
// re-carried id leaves, a forgotten workspace takes its ids, the identity
|
|
345
|
+
// end clears the rest.
|
|
346
|
+
const closedAt = new Map<string, number>()
|
|
347
|
+
// The status annotation every virtualized title currently carries (T34).
|
|
348
|
+
let annotation: MergerAnnotation = 'none'
|
|
349
|
+
const now = options.now ?? Date.now
|
|
350
|
+
|
|
351
|
+
const virtualize = (id: string): string => toVirtual(identity.serverId, id)
|
|
352
|
+
// Only SHOWN workspaces travel in order frames (T56): a hidden group has no
|
|
353
|
+
// position the UI knows about.
|
|
354
|
+
const remoteIds = (): string[] => [...remote.keys()].filter((id) => shown.has(id)).map(virtualize)
|
|
355
|
+
const remoteArchivedVirtual = (): string[] => remoteArchived.map(virtualize)
|
|
356
|
+
const remotePinnedVirtual = (): string[] => remotePinned.map(virtualize)
|
|
357
|
+
|
|
358
|
+
/** Learn the session ids one remote workspace record carries: each id is
|
|
359
|
+
* remembered under THIS workspace as its last home (originals, cumulative). */
|
|
360
|
+
function learnSessions(record: Record<string, unknown>, workspaceId: string): void {
|
|
361
|
+
if (!Array.isArray(record.sessionIds)) return
|
|
362
|
+
for (const id of record.sessionIds) if (typeof id === 'string') sessionHome.set(id, workspaceId)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Drop the ids whose last home was this workspace — tombstones die with
|
|
366
|
+
* their group (a server remove / a workspace diffed away; a session that
|
|
367
|
+
* moved on lives under its NEW home and survives this). */
|
|
368
|
+
function forgetWorkspace(workspaceId: string): void {
|
|
369
|
+
for (const [id, home] of sessionHome) {
|
|
370
|
+
if (home === workspaceId) {
|
|
371
|
+
sessionHome.delete(id)
|
|
372
|
+
closedAt.delete(id)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** T65: stamp the ids that JUST left a workspace's live list (their
|
|
378
|
+
* tombstones were created by this frame) and clear the ones that came
|
|
379
|
+
* back. The grace window is judged from these stamps. */
|
|
380
|
+
function noteClosedTransitions(prevRecord: Record<string, unknown> | undefined, nextRecord: Record<string, unknown>): void {
|
|
381
|
+
const prevLive = prevRecord === undefined ? [] : stringList(prevRecord.sessionIds)
|
|
382
|
+
const nextLive = stringList(nextRecord.sessionIds)
|
|
383
|
+
const stamp = now()
|
|
384
|
+
for (const id of prevLive) {
|
|
385
|
+
if (!nextLive.includes(id)) closedAt.set(id, stamp)
|
|
386
|
+
}
|
|
387
|
+
for (const id of nextLive) {
|
|
388
|
+
if (closedAt.has(id)) closedAt.delete(id)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The CURRENT session as an ORIGINAL id of THIS server — undefined when
|
|
393
|
+
* no readable current is known, it is a local session, or it belongs to
|
|
394
|
+
* another server. All three mean "no remote session of ours is open". */
|
|
395
|
+
function currentOriginal(): string | undefined {
|
|
396
|
+
if (currentRaw === undefined) return undefined
|
|
397
|
+
const parts = fromVirtual(currentRaw)
|
|
398
|
+
return parts !== undefined && parts.serverId === identity.serverId ? parts.id : undefined
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The hide grace (T65): a freshly closed session is not hidden for this
|
|
402
|
+
* long, because the browser's current-session report can lag the user's
|
|
403
|
+
* switch by up to a poll interval — a close frame naming the session the
|
|
404
|
+
* user JUST opened would read the stale value and archive the open page
|
|
405
|
+
* (the host's clearArchivedCurrent kicks it home). The 1s poll re-judges
|
|
406
|
+
* with the fresh report once the grace passes. */
|
|
407
|
+
const TOMBSTONE_HIDE_GRACE_MS = 2_000
|
|
408
|
+
|
|
409
|
+
/** T58: whether the tombstone of `id` is HIDDEN (moved into the merged
|
|
410
|
+
* archived set) rather than kept visible in its group. Only an actually
|
|
411
|
+
* readable current-session signal hides anything: before the first
|
|
412
|
+
* {@link WorkspaceMerger.setCurrentSession} call, every tombstone stays
|
|
413
|
+
* (the conservative fallback) — and a JUST-created tombstone stays for
|
|
414
|
+
* the grace regardless (T65). */
|
|
415
|
+
function hideTombstone(id: string): boolean {
|
|
416
|
+
if (!currentReadable) return false
|
|
417
|
+
const closed = closedAt.get(id)
|
|
418
|
+
if (closed !== undefined && now() - closed < TOMBSTONE_HIDE_GRACE_MS) return false
|
|
419
|
+
return currentOriginal() !== id
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** T58: re-derive {@link hiddenSessions} from the live tombstone set —
|
|
423
|
+
* tombstones hide unless they ARE the current session (or no readable
|
|
424
|
+
* current is known, or the tombstone is inside the T65 hide grace);
|
|
425
|
+
* live-carried ids and ids that (re-)became the current session leave the
|
|
426
|
+
* set. Since T65 the hide/reveal transitions move ONLY the archived set —
|
|
427
|
+
* the session stays in its group's forwarded copy either way — so the
|
|
428
|
+
* caller emits just the merged archived frame when anything changed. */
|
|
429
|
+
function syncHiddenSessions(): { hidden: string[]; revealed: string[] } {
|
|
430
|
+
const hidden: string[] = []
|
|
431
|
+
const revealed: string[] = []
|
|
432
|
+
for (const [id, home] of sessionHome) {
|
|
433
|
+
const record = remote.get(home)
|
|
434
|
+
if (record === undefined) continue
|
|
435
|
+
if (stringList(record.sessionIds).includes(id)) {
|
|
436
|
+
if (hiddenSessions.delete(id)) revealed.push(id)
|
|
437
|
+
continue
|
|
438
|
+
}
|
|
439
|
+
if (hideTombstone(id)) {
|
|
440
|
+
if (!hiddenSessions.has(id)) {
|
|
441
|
+
hiddenSessions.add(id)
|
|
442
|
+
hidden.push(id)
|
|
443
|
+
}
|
|
444
|
+
} else if (hiddenSessions.delete(id)) {
|
|
445
|
+
revealed.push(id)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return { hidden, revealed }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** One workspace record as the UI should see it: virtualized, PLUS the
|
|
452
|
+
* tombstones — ids whose last home is this workspace but that the server's
|
|
453
|
+
* record no longer carries — appended at the end of `sessionIds`
|
|
454
|
+
* (CP4-client-fix2). Liveness is judged against the record's ORIGINAL
|
|
455
|
+
* sessionIds (the registry stores originals; the forwarded copy is
|
|
456
|
+
* virtualized). Since T65 a HIDDEN tombstone rides the group too: the
|
|
457
|
+
* session stays in its group so the host's「显示已归档」filter can show it
|
|
458
|
+
* in place — hiding is purely the archived set. */
|
|
459
|
+
function forwardWorkspace(record: Record<string, unknown>): Record<string, unknown> {
|
|
460
|
+
const out = virtualizeWorkspace(record, identity, annotation)
|
|
461
|
+
const workspaceId = typeof record.workspaceId === 'string' ? record.workspaceId : undefined
|
|
462
|
+
const live = stringList(record.sessionIds)
|
|
463
|
+
if (workspaceId === undefined || !Array.isArray(out.sessionIds)) return out
|
|
464
|
+
for (const [id, home] of sessionHome) {
|
|
465
|
+
if (home === workspaceId && !live.includes(id)) out.sessionIds.push(virtualize(id))
|
|
466
|
+
}
|
|
467
|
+
return out
|
|
468
|
+
}
|
|
469
|
+
/** T56: the forwarded record when the UI may see this workspace, else
|
|
470
|
+
* undefined — a never-shown workspace is forwarded only once its forwarded
|
|
471
|
+
* sessionIds first carries VISIBLE content, and forwarding it marks it
|
|
472
|
+
* shown for good. Only call on paths that actually emit (after the local
|
|
473
|
+
* baseline has passed): a mark here is a claim that the UI now holds the
|
|
474
|
+
* group. Visible means NOT hidden (T65): the host draws a group heading
|
|
475
|
+
* even when every member is archived-invisible under the default filter,
|
|
476
|
+
* so a group whose members are all hidden must not be first-shown —
|
|
477
|
+
* the forwarded copy itself still carries the hidden ids. */
|
|
478
|
+
function forwardVisible(record: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
479
|
+
const id = typeof record.workspaceId === 'string' ? record.workspaceId : undefined
|
|
480
|
+
if (id !== undefined && shown.has(id)) return forwardWorkspace(record)
|
|
481
|
+
const out = forwardWorkspace(record)
|
|
482
|
+
const ids = Array.isArray(out.sessionIds) ? out.sessionIds : []
|
|
483
|
+
let visible = false
|
|
484
|
+
for (const virtualId of ids) {
|
|
485
|
+
const parts = fromVirtual(virtualId)
|
|
486
|
+
if (parts !== undefined && parts.serverId === identity.serverId && hiddenSessions.has(parts.id)) continue
|
|
487
|
+
visible = true
|
|
488
|
+
break
|
|
489
|
+
}
|
|
490
|
+
if (!visible) return undefined
|
|
491
|
+
if (id !== undefined) shown.add(id)
|
|
492
|
+
return out
|
|
493
|
+
}
|
|
494
|
+
const virtualWorkspaces = (): Record<string, unknown>[] => {
|
|
495
|
+
const out: Record<string, unknown>[] = []
|
|
496
|
+
for (const record of remote.values()) {
|
|
497
|
+
const forwarded = forwardVisible(record)
|
|
498
|
+
if (forwarded !== undefined) out.push(forwarded)
|
|
499
|
+
}
|
|
500
|
+
return out
|
|
501
|
+
}
|
|
502
|
+
const upsertFrames = (): unknown[] => virtualWorkspaces().map((workspace) => ({ type: 'upsert', workspace }))
|
|
503
|
+
const mergedOrderFrame = (): unknown => ({ type: 'order', workspaceIds: [...localOrder, ...remoteIds()] })
|
|
504
|
+
/** The merged archived set: local, then remote-archived, then the T58
|
|
505
|
+
* hidden closed-remote sessions, deduplicated in that order. Closed
|
|
506
|
+
* sessions that are NOT hidden keep their group slot as tombstones
|
|
507
|
+
* (CP4-client-fix2), and the RT navigation guard keys on exactly this
|
|
508
|
+
* list — which is why the CURRENT session never enters it. */
|
|
509
|
+
function mergedArchivedIds(): string[] {
|
|
510
|
+
const seen = new Set<string>()
|
|
511
|
+
const out: string[] = []
|
|
512
|
+
const add = (ids: string[]): void => {
|
|
513
|
+
for (const id of ids) {
|
|
514
|
+
if (seen.has(id)) continue
|
|
515
|
+
seen.add(id)
|
|
516
|
+
out.push(id)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
add(localArchived)
|
|
520
|
+
add(remoteArchivedVirtual())
|
|
521
|
+
add([...hiddenSessions].map(virtualize))
|
|
522
|
+
return out
|
|
523
|
+
}
|
|
524
|
+
const mergedArchivedFrame = (): unknown => ({ type: 'archived', archivedSessionIds: mergedArchivedIds() })
|
|
525
|
+
const mergedPinnedFrame = (): unknown => ({
|
|
526
|
+
type: 'pinned',
|
|
527
|
+
pinnedSessionIds: [...localPinned, ...remotePinnedVirtual()],
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
/** Learn from a local frame without emitting anything. */
|
|
531
|
+
function observeLocal(frame: Record<string, unknown>): void {
|
|
532
|
+
if (frame.type === 'baseline') {
|
|
533
|
+
const value = isPlainObject(frame.value) ? frame.value : {}
|
|
534
|
+
localOrder = idList(value.items)
|
|
535
|
+
localArchived = stringList(value.archivedSessionIds)
|
|
536
|
+
localPinned = stringList(value.pinnedSessionIds)
|
|
537
|
+
localSeen = true
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
if (frame.type === 'order') {
|
|
541
|
+
localOrder = stringList(frame.workspaceIds)
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
if (frame.type === 'archived') {
|
|
545
|
+
localArchived = stringList(frame.archivedSessionIds)
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
if (frame.type === 'pinned') {
|
|
549
|
+
localPinned = stringList(frame.pinnedSessionIds)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function drop(message: string): void {
|
|
554
|
+
onDiagnostic?.(message)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
get serverId(): string {
|
|
559
|
+
return identity.serverId
|
|
560
|
+
},
|
|
561
|
+
get serverName(): string {
|
|
562
|
+
return identity.serverName
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
retarget(next: MergerIdentity): void {
|
|
566
|
+
identity = { serverId: next.serverId, serverName: next.serverName }
|
|
567
|
+
},
|
|
568
|
+
|
|
569
|
+
onLocal(frame: unknown): unknown[] {
|
|
570
|
+
if (!isPlainObject(frame)) return [frame]
|
|
571
|
+
observeLocal(frame)
|
|
572
|
+
// The local order carries the remote groups at its tail ONLY once
|
|
573
|
+
// remote state exists — before that every local frame is verbatim.
|
|
574
|
+
if (frame.type === 'baseline') {
|
|
575
|
+
// T58: the cached tombstones become decidable at the flush — sync
|
|
576
|
+
// BEFORE building, so the baseline's items (hidden tombstones
|
|
577
|
+
// dropped) and its archived list (hidden ids added) agree atomically
|
|
578
|
+
// in the one frame.
|
|
579
|
+
syncHiddenSessions()
|
|
580
|
+
const shownWorkspaces = virtualWorkspaces()
|
|
581
|
+
// Nothing VISIBLE (T56 — a remote full of hidden empty groups counts
|
|
582
|
+
// as nothing) and no lists to merge: the frame passes verbatim.
|
|
583
|
+
if (
|
|
584
|
+
shownWorkspaces.length === 0 &&
|
|
585
|
+
remoteArchived.length === 0 &&
|
|
586
|
+
remotePinned.length === 0 &&
|
|
587
|
+
sessionHome.size === 0 &&
|
|
588
|
+
hiddenSessions.size === 0
|
|
589
|
+
) {
|
|
590
|
+
return [frame]
|
|
591
|
+
}
|
|
592
|
+
const value = isPlainObject(frame.value) ? frame.value : {}
|
|
593
|
+
const items = Array.isArray(value.items) ? value.items : []
|
|
594
|
+
// The baseline's own order already shows the remote groups; the
|
|
595
|
+
// upserts that follow restate their content (the cached-remote path
|
|
596
|
+
// never emits a separate order frame).
|
|
597
|
+
return [
|
|
598
|
+
{
|
|
599
|
+
...frame,
|
|
600
|
+
value: {
|
|
601
|
+
...value,
|
|
602
|
+
items: [...items, ...shownWorkspaces],
|
|
603
|
+
archivedSessionIds: mergedArchivedIds(),
|
|
604
|
+
pinnedSessionIds: [...localPinned, ...remotePinnedVirtual()],
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
...upsertFrames(),
|
|
608
|
+
]
|
|
609
|
+
}
|
|
610
|
+
if (frame.type === 'order') {
|
|
611
|
+
if (remote.size === 0) return [frame]
|
|
612
|
+
return [mergedOrderFrame()]
|
|
613
|
+
}
|
|
614
|
+
if (frame.type === 'archived') {
|
|
615
|
+
// T58-fix: with hidden sessions around, the local list must travel
|
|
616
|
+
// merged — verbatim it would WIPE the hidden ids from the UI's
|
|
617
|
+
// archived set and flush them back as 「未分组」 strays.
|
|
618
|
+
if (remoteArchived.length === 0 && hiddenSessions.size === 0) return [frame]
|
|
619
|
+
return [mergedArchivedFrame()]
|
|
620
|
+
}
|
|
621
|
+
if (frame.type === 'pinned') {
|
|
622
|
+
if (remotePinned.length === 0) return [frame]
|
|
623
|
+
return [mergedPinnedFrame()]
|
|
624
|
+
}
|
|
625
|
+
// upsert / remove / anything else local: purely local facts.
|
|
626
|
+
return [frame]
|
|
627
|
+
},
|
|
628
|
+
|
|
629
|
+
onRemote(frame: unknown): unknown[] {
|
|
630
|
+
if (!isPlainObject(frame)) {
|
|
631
|
+
drop('dropped a non-object workspace/follow frame from the relay')
|
|
632
|
+
return []
|
|
633
|
+
}
|
|
634
|
+
switch (frame.type) {
|
|
635
|
+
case 'baseline': {
|
|
636
|
+
const value = isPlainObject(frame.value) ? frame.value : undefined
|
|
637
|
+
// Build the new remote truth beside the shown one, then DIFF
|
|
638
|
+
// (T23b2-fix): a reconnecting baseline must not blindly re-add —
|
|
639
|
+
// the upserts refresh content, and the only removes it synthesizes
|
|
640
|
+
// are workspaces the server dropped (deleted / no longer shared).
|
|
641
|
+
const next = new Map<string, Record<string, unknown>>()
|
|
642
|
+
if (value !== undefined && Array.isArray(value.items)) {
|
|
643
|
+
for (const item of value.items) {
|
|
644
|
+
if (isPlainObject(item) && typeof item.workspaceId === 'string') next.set(item.workspaceId, { ...item })
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const nextArchived = value === undefined ? [] : stringList(value.archivedSessionIds)
|
|
648
|
+
const nextPinned = value === undefined ? [] : stringList(value.pinnedSessionIds)
|
|
649
|
+
// The session inventory is cumulative — a baseline only ever adds
|
|
650
|
+
// known session ids (each under the workspace now carrying it).
|
|
651
|
+
for (const [id, record] of next) learnSessions(record, id)
|
|
652
|
+
// Never a second baseline: the UI has either seen local data (which
|
|
653
|
+
// it must keep) or nothing (cache only).
|
|
654
|
+
if (!localSeen) {
|
|
655
|
+
remote = next
|
|
656
|
+
remoteArchived = nextArchived
|
|
657
|
+
remotePinned = nextPinned
|
|
658
|
+
return []
|
|
659
|
+
}
|
|
660
|
+
const out: unknown[] = []
|
|
661
|
+
// The remote truth moves FIRST (the sync and every frame below are
|
|
662
|
+
// built against the NEW records; close stamps start the grace per
|
|
663
|
+
// dropped id — T65), then the diffed upserts / removes and the
|
|
664
|
+
// canonical order / archived / pinned. The trailing archived frame
|
|
665
|
+
// covers any hide/reveal the new records caused: since T65 hiding
|
|
666
|
+
// moves only the archived set, nothing can flash in between.
|
|
667
|
+
const prev = remote
|
|
668
|
+
remote = next
|
|
669
|
+
remoteArchived = nextArchived
|
|
670
|
+
remotePinned = nextPinned
|
|
671
|
+
for (const [id, record] of next) noteClosedTransitions(prev.get(id), record)
|
|
672
|
+
syncHiddenSessions()
|
|
673
|
+
for (const [, record] of next) {
|
|
674
|
+
const forwarded = forwardVisible(record)
|
|
675
|
+
if (forwarded !== undefined) out.push({ type: 'upsert', workspace: forwarded })
|
|
676
|
+
}
|
|
677
|
+
for (const id of prev.keys()) {
|
|
678
|
+
if (next.has(id)) continue
|
|
679
|
+
forgetWorkspace(id)
|
|
680
|
+
// Only a workspace the UI was SHOWN may receive a remove (T56) —
|
|
681
|
+
// the model blacklists the id forever; a never-shown one is
|
|
682
|
+
// unknown to the UI and leaves silently.
|
|
683
|
+
if (shown.has(id)) out.push({ type: 'remove', workspaceId: virtualize(id) })
|
|
684
|
+
}
|
|
685
|
+
out.push(mergedOrderFrame(), mergedArchivedFrame(), mergedPinnedFrame())
|
|
686
|
+
return out
|
|
687
|
+
}
|
|
688
|
+
case 'upsert': {
|
|
689
|
+
if (!isPlainObject(frame.workspace) || typeof frame.workspace.workspaceId !== 'string') {
|
|
690
|
+
drop('dropped a workspace upsert without a workspaceId')
|
|
691
|
+
return []
|
|
692
|
+
}
|
|
693
|
+
const workspace: Record<string, unknown> = frame.workspace
|
|
694
|
+
const id = workspace.workspaceId as string
|
|
695
|
+
// T65: stamp the close first — the ids the OLD record carried live
|
|
696
|
+
// and this one drops just became tombstones (the grace starts now).
|
|
697
|
+
const prevRecord = remote.get(id)
|
|
698
|
+
remote.set(id, { ...workspace })
|
|
699
|
+
learnSessions(workspace, id)
|
|
700
|
+
noteClosedTransitions(prevRecord, workspace)
|
|
701
|
+
if (!localSeen) return []
|
|
702
|
+
// T65: hide/reveal moves only the archived set (the session keeps
|
|
703
|
+
// its group slot), so the group's upsert and the archived frame no
|
|
704
|
+
// longer race — one trailing archived frame after the group's own
|
|
705
|
+
// update covers both directions.
|
|
706
|
+
const changed = syncHiddenSessions()
|
|
707
|
+
// The tombstone-padded upsert is the whole update; a workspace the
|
|
708
|
+
// UI has not seen (new, or hidden until now — T56) additionally
|
|
709
|
+
// needs a position.
|
|
710
|
+
const wasShown = shown.has(id)
|
|
711
|
+
const forwarded = forwardVisible(workspace)
|
|
712
|
+
const out: unknown[] = []
|
|
713
|
+
if (forwarded !== undefined) {
|
|
714
|
+
out.push({ type: 'upsert', workspace: forwarded })
|
|
715
|
+
if (!wasShown) out.push(mergedOrderFrame())
|
|
716
|
+
}
|
|
717
|
+
if (changed.hidden.length > 0 || changed.revealed.length > 0) out.push(mergedArchivedFrame())
|
|
718
|
+
return out
|
|
719
|
+
}
|
|
720
|
+
case 'remove': {
|
|
721
|
+
const id = typeof frame.workspaceId === 'string' ? frame.workspaceId : undefined
|
|
722
|
+
if (id === undefined) {
|
|
723
|
+
drop('dropped a workspace remove without a workspaceId')
|
|
724
|
+
return []
|
|
725
|
+
}
|
|
726
|
+
// A remove of a workspace this merger never knew is a no-op: the UI
|
|
727
|
+
// was never shown it.
|
|
728
|
+
const removed = remote.get(id)
|
|
729
|
+
if (removed === undefined) return []
|
|
730
|
+
remote.delete(id)
|
|
731
|
+
// The group's tombstones die with it (CP4-client-fix2).
|
|
732
|
+
forgetWorkspace(id)
|
|
733
|
+
if (!localSeen) return []
|
|
734
|
+
// T56: a workspace the UI was never shown must not receive a
|
|
735
|
+
// remove — the model would blacklist the id against any future
|
|
736
|
+
// re-share of the same workspace id.
|
|
737
|
+
if (!shown.has(id)) return []
|
|
738
|
+
return [{ type: 'remove', workspaceId: virtualize(id) }, mergedOrderFrame()]
|
|
739
|
+
}
|
|
740
|
+
case 'order': {
|
|
741
|
+
if (!Array.isArray(frame.workspaceIds)) {
|
|
742
|
+
drop('dropped a workspace order frame without a workspaceIds array')
|
|
743
|
+
return []
|
|
744
|
+
}
|
|
745
|
+
// Same reorder rule as the server's own state (relay-filter.ts):
|
|
746
|
+
// mentioned ids first in frame order, unmentioned ones keep their
|
|
747
|
+
// relative order behind them — a partial reorder drops nothing.
|
|
748
|
+
const reordered = new Map<string, Record<string, unknown>>()
|
|
749
|
+
for (const id of frame.workspaceIds) {
|
|
750
|
+
if (typeof id !== 'string') continue
|
|
751
|
+
const workspace = remote.get(id)
|
|
752
|
+
if (workspace !== undefined) reordered.set(id, workspace)
|
|
753
|
+
}
|
|
754
|
+
for (const [id, workspace] of remote) {
|
|
755
|
+
if (!reordered.has(id)) reordered.set(id, workspace)
|
|
756
|
+
}
|
|
757
|
+
remote.clear()
|
|
758
|
+
for (const [id, workspace] of reordered) remote.set(id, workspace)
|
|
759
|
+
if (!localSeen) return []
|
|
760
|
+
return [mergedOrderFrame()]
|
|
761
|
+
}
|
|
762
|
+
case 'archived':
|
|
763
|
+
remoteArchived = stringList(frame.archivedSessionIds)
|
|
764
|
+
if (!localSeen) return []
|
|
765
|
+
return [mergedArchivedFrame()]
|
|
766
|
+
case 'pinned':
|
|
767
|
+
remotePinned = stringList(frame.pinnedSessionIds)
|
|
768
|
+
if (!localSeen) return []
|
|
769
|
+
return [mergedPinnedFrame()]
|
|
770
|
+
default:
|
|
771
|
+
drop(`dropped a workspace/follow frame of unknown type ${String(frame.type)}`)
|
|
772
|
+
return []
|
|
773
|
+
}
|
|
774
|
+
},
|
|
775
|
+
|
|
776
|
+
onRemoteDown(): unknown[] {
|
|
777
|
+
// Deliberately nothing: the shown state STAYS (the UI keeps the last
|
|
778
|
+
// projection visible while the carrier is gone), so the reconnecting
|
|
779
|
+
// baseline can diff against it instead of re-adding through the UI's
|
|
780
|
+
// remove-blacklist. Pre-baseline cached state stays cached.
|
|
781
|
+
return []
|
|
782
|
+
},
|
|
783
|
+
|
|
784
|
+
onServerRenamed(): unknown[] {
|
|
785
|
+
// The caller has already retarget()ed to the new name; re-upserting the
|
|
786
|
+
// shown workspaces under the CURRENT identity is the whole update.
|
|
787
|
+
if (!localSeen || remote.size === 0) return []
|
|
788
|
+
return upsertFrames()
|
|
789
|
+
},
|
|
790
|
+
|
|
791
|
+
setStatus(next: MergerAnnotation): void {
|
|
792
|
+
annotation = next
|
|
793
|
+
},
|
|
794
|
+
|
|
795
|
+
onStatusChanged(): unknown[] {
|
|
796
|
+
if (!localSeen || remote.size === 0) return []
|
|
797
|
+
return upsertFrames()
|
|
798
|
+
},
|
|
799
|
+
|
|
800
|
+
setCurrentSession(sessionId: string | undefined): unknown[] {
|
|
801
|
+
currentRaw = sessionId
|
|
802
|
+
currentReadable = true
|
|
803
|
+
if (!localSeen) return []
|
|
804
|
+
// T65: hide/reveal moves ONLY the archived set — the session keeps its
|
|
805
|
+
// group slot either way, so the whole update is the merged archived
|
|
806
|
+
// frame (no group upserts, no ordering dance; the current session can
|
|
807
|
+
// never enter the set, and a re-share removes its id from it).
|
|
808
|
+
const changed = syncHiddenSessions()
|
|
809
|
+
if (changed.hidden.length === 0 && changed.revealed.length === 0) return []
|
|
810
|
+
return [mergedArchivedFrame()]
|
|
811
|
+
},
|
|
812
|
+
|
|
813
|
+
get hasPendingHide(): boolean {
|
|
814
|
+
// Some tombstone is being kept visible: it is not in the hidden set
|
|
815
|
+
// (either it IS the current session, or no readable current made any
|
|
816
|
+
// decision yet) — the caller keeps polling until it hides.
|
|
817
|
+
for (const [id, home] of sessionHome) {
|
|
818
|
+
if (hiddenSessions.has(id)) continue
|
|
819
|
+
const record = remote.get(home)
|
|
820
|
+
if (record !== undefined && !stringList(record.sessionIds).includes(id)) return true
|
|
821
|
+
}
|
|
822
|
+
return false
|
|
823
|
+
},
|
|
824
|
+
|
|
825
|
+
onRemoteGone(): unknown[] {
|
|
826
|
+
const out: unknown[] = []
|
|
827
|
+
// The UI only ever SAW remote data when the local baseline had passed —
|
|
828
|
+
// cached pre-baseline state is discarded silently instead. And only
|
|
829
|
+
// SHOWN workspaces may leave via a remove (T56): a never-shown one is
|
|
830
|
+
// unknown to the UI and just leaves the state. The archived/pinned
|
|
831
|
+
// restore still rides along whenever remote sessions reached those
|
|
832
|
+
// lists (session lists merge independently of workspace visibility).
|
|
833
|
+
const removedIds = [...remote.keys()].filter((id) => shown.has(id))
|
|
834
|
+
// T58-fix: hidden sessions alone also demand the restore — their ids
|
|
835
|
+
// sit in the UI's archived set and must leave it with the identity.
|
|
836
|
+
if (localSeen && (removedIds.length > 0 || remoteArchived.length > 0 || remotePinned.length > 0 || hiddenSessions.size > 0)) {
|
|
837
|
+
for (const id of removedIds) out.push({ type: 'remove', workspaceId: virtualize(id) })
|
|
838
|
+
out.push({ type: 'order', workspaceIds: [...localOrder] })
|
|
839
|
+
out.push({ type: 'archived', archivedSessionIds: [...localArchived] })
|
|
840
|
+
out.push({ type: 'pinned', pinnedSessionIds: [...localPinned] })
|
|
841
|
+
}
|
|
842
|
+
remote.clear()
|
|
843
|
+
remoteArchived = []
|
|
844
|
+
remotePinned = []
|
|
845
|
+
// The tombstone registry dies with the identity (a new server mints new
|
|
846
|
+
// ids); the archived frame above already restored the local-only set.
|
|
847
|
+
sessionHome = new Map()
|
|
848
|
+
// So do the T65 close stamps, the T58 hidden sessions and the
|
|
849
|
+
// current-session knowledge (a NEW server may reuse original
|
|
850
|
+
// workspace/session ids, and a stale entry would forward or mis-hide
|
|
851
|
+
// the new server's sessions).
|
|
852
|
+
closedAt.clear()
|
|
853
|
+
hiddenSessions.clear()
|
|
854
|
+
currentRaw = undefined
|
|
855
|
+
currentReadable = false
|
|
856
|
+
// So does the shown set: a NEW server may reuse original workspace ids,
|
|
857
|
+
// and a stale entry would forward the new server's empty groups.
|
|
858
|
+
shown.clear()
|
|
859
|
+
return out
|
|
860
|
+
},
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
export interface ControlMergerOptions {
|
|
865
|
+
serverId: string
|
|
866
|
+
serverName?: string
|
|
867
|
+
onDiagnostic?: (message: string) => void
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
export interface ControlMerger {
|
|
871
|
+
readonly serverId: string
|
|
872
|
+
readonly serverName: string
|
|
873
|
+
/** Local control frames pass through untouched; the local BASELINE also
|
|
874
|
+
* flushes anything the remote side buffered before it arrived. */
|
|
875
|
+
onLocal(frame: unknown): unknown[]
|
|
876
|
+
/** Remote baseline → per-session per-key `projection` frames; remote
|
|
877
|
+
* projection updates → virtualized. Unknown types are dropped. Frames
|
|
878
|
+
* arriving before the local baseline are buffered (the host's snapshot
|
|
879
|
+
* stream treats an update before the opening snapshot as a protocol
|
|
880
|
+
* violation and kills the stream). */
|
|
881
|
+
onRemote(frame: unknown): unknown[]
|
|
882
|
+
/** Always [] — a control stream has nothing to remove on a temporary
|
|
883
|
+
* remote end (the workspace merger owns the group). */
|
|
884
|
+
onRemoteDown(): unknown[]
|
|
885
|
+
/** Always [] — leftover projection state for vanished virtual sessions is
|
|
886
|
+
* harmless; the workspace merger removes the group. */
|
|
887
|
+
onRemoteGone(): unknown[]
|
|
888
|
+
/** Always [] — projection frames carry no display name. */
|
|
889
|
+
onServerRenamed(): unknown[]
|
|
890
|
+
/** Accepted and ignored: a status annotation is a TITLE concern, and
|
|
891
|
+
* control projections carry no titles. */
|
|
892
|
+
setStatus(annotation: MergerAnnotation): void
|
|
893
|
+
/** Always [] — nothing shown here could carry an annotation. */
|
|
894
|
+
onStatusChanged(): unknown[]
|
|
895
|
+
/** Accepted and ignored; always answers []. */
|
|
896
|
+
setCurrentSession(sessionId: string | undefined): unknown[]
|
|
897
|
+
/** Always false — the control merger hides nothing. */
|
|
898
|
+
readonly hasPendingHide: boolean
|
|
899
|
+
retarget(identity: MergerIdentity): void
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* The session/control merger. The control stream is key-value projection
|
|
904
|
+
* traffic with no ordering constraints between sessions, so it needs no
|
|
905
|
+
* state beyond the pre-baseline buffer: local frames pass through, remote
|
|
906
|
+
* frames get their session id virtualized, and a remote baseline is EXPLODED
|
|
907
|
+
* into one `projection` frame per key (never a second baseline).
|
|
908
|
+
*/
|
|
909
|
+
export function createControlMerger(options: ControlMergerOptions): ControlMerger {
|
|
910
|
+
const onDiagnostic = options.onDiagnostic
|
|
911
|
+
let identity: MergerIdentity = { serverId: options.serverId, serverName: options.serverName ?? '' }
|
|
912
|
+
const virtualize = (id: string): string => toVirtual(identity.serverId, id)
|
|
913
|
+
// Remote output converted but not yet shown: the UI's snapshot stream
|
|
914
|
+
// throws `emitted an update before its opening snapshot` — a protocol
|
|
915
|
+
// violation that permanently fails the stream — so nothing may leave
|
|
916
|
+
// before the local baseline has passed.
|
|
917
|
+
let localSeen = false
|
|
918
|
+
let buffered: unknown[] = []
|
|
919
|
+
|
|
920
|
+
/** Convert one remote control frame into the projection frames the UI
|
|
921
|
+
* understands (empty for anything malformed). */
|
|
922
|
+
function convert(frame: Record<string, unknown>): unknown[] {
|
|
923
|
+
if (frame.type === 'baseline') {
|
|
924
|
+
const value = isPlainObject(frame.value) ? frame.value : undefined
|
|
925
|
+
const projections = value !== undefined && isPlainObject(value.projections) ? value.projections : {}
|
|
926
|
+
const out: unknown[] = []
|
|
927
|
+
for (const [sessionId, projection] of Object.entries(projections)) {
|
|
928
|
+
if (!isPlainObject(projection)) continue
|
|
929
|
+
const values = isPlainObject(projection.values) ? projection.values : {}
|
|
930
|
+
const asOfSeq = projection.asOfSeq
|
|
931
|
+
// A zero-event session's asOfSeq is -1, and the UI's SessionSeq
|
|
932
|
+
// THROWS on a negative seq — one such frame would permanently stop
|
|
933
|
+
// the whole control stream. Skip the record (one diagnostic) instead
|
|
934
|
+
// of clamping: the UI compares `seq <= held → drop`, so a clamped 0
|
|
935
|
+
// would swallow the real seq-0 entry when it arrives.
|
|
936
|
+
if (typeof asOfSeq !== 'number' || !Number.isSafeInteger(asOfSeq) || asOfSeq < 0) {
|
|
937
|
+
onDiagnostic?.(
|
|
938
|
+
`skipped control projections for ${sessionId}: asOfSeq ${String(asOfSeq)} is not a usable sequence number`,
|
|
939
|
+
)
|
|
940
|
+
continue
|
|
941
|
+
}
|
|
942
|
+
for (const key of Object.keys(values)) {
|
|
943
|
+
// The baseline gives one sequence number per projection record
|
|
944
|
+
// (`asOfSeq`) — every key of that record rides it. A modelSelection
|
|
945
|
+
// value additionally gets its provider ids virtualized (T52) so the
|
|
946
|
+
// UI matches it against the merged catalog's virtual groups —
|
|
947
|
+
// UNCONDITIONALLY (T52-fix3): the host's projection store is
|
|
948
|
+
// first-write-wins per seq, so a catalog-gated original value would
|
|
949
|
+
// occupy the seq and the later virtual rewrite could never land.
|
|
950
|
+
out.push({
|
|
951
|
+
type: 'projection',
|
|
952
|
+
sessionId: virtualize(sessionId),
|
|
953
|
+
key,
|
|
954
|
+
value: key === 'modelSelection' ? virtualizeModelSelectionValue(values[key], identity.serverId) : values[key],
|
|
955
|
+
seq: asOfSeq,
|
|
956
|
+
})
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return out
|
|
960
|
+
}
|
|
961
|
+
if (frame.type === 'projection') {
|
|
962
|
+
if (typeof frame.sessionId !== 'string') {
|
|
963
|
+
onDiagnostic?.('dropped a session/control projection frame without a sessionId')
|
|
964
|
+
return []
|
|
965
|
+
}
|
|
966
|
+
if (typeof frame.seq !== 'number' || !Number.isSafeInteger(frame.seq) || frame.seq < 0) {
|
|
967
|
+
// Same SessionSeq hazard on live updates (a server reboot resets the
|
|
968
|
+
// counters — a -1 or fractional seq must not kill the stream).
|
|
969
|
+
onDiagnostic?.(`dropped a session/control projection frame with unusable seq ${String(frame.seq)}`)
|
|
970
|
+
return []
|
|
971
|
+
}
|
|
972
|
+
return [
|
|
973
|
+
{
|
|
974
|
+
...frame,
|
|
975
|
+
sessionId: virtualize(frame.sessionId),
|
|
976
|
+
// The modelSelection update's providers go virtual with the session
|
|
977
|
+
// id (T52) — same unconditional rewrite as the exploded baseline
|
|
978
|
+
// above (T52-fix3: first-write-wins per seq forbids a catalog gate).
|
|
979
|
+
...(frame.key === 'modelSelection'
|
|
980
|
+
? { value: virtualizeModelSelectionValue(frame.value, identity.serverId) }
|
|
981
|
+
: {}),
|
|
982
|
+
},
|
|
983
|
+
]
|
|
984
|
+
}
|
|
985
|
+
onDiagnostic?.(`dropped a session/control frame of unknown type ${String(frame.type)}`)
|
|
986
|
+
return []
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return {
|
|
990
|
+
get serverId(): string {
|
|
991
|
+
return identity.serverId
|
|
992
|
+
},
|
|
993
|
+
get serverName(): string {
|
|
994
|
+
return identity.serverName
|
|
995
|
+
},
|
|
996
|
+
|
|
997
|
+
// TODO(seq-epoch): the server owns `seq` and restarts it from 0 on
|
|
998
|
+
// reboot, while the UI drops any projection whose seq is ≤ the value it
|
|
999
|
+
// already holds — so after a server restart every remote session's
|
|
1000
|
+
// projections stay frozen at their pre-restart values until the live seq
|
|
1001
|
+
// climbs past them. A fix would offset each remote stream generation's
|
|
1002
|
+
// seq (e.g. by a per-generation epoch base) and shift the forwarded
|
|
1003
|
+
// `session/projections` results by the same offset so both routes agree.
|
|
1004
|
+
// Deliberately out of scope for T23b2-fix; recorded here where the seq
|
|
1005
|
+
// conversion happens.
|
|
1006
|
+
|
|
1007
|
+
retarget(next: MergerIdentity): void {
|
|
1008
|
+
identity = { serverId: next.serverId, serverName: next.serverName }
|
|
1009
|
+
},
|
|
1010
|
+
|
|
1011
|
+
onLocal(frame: unknown): unknown[] {
|
|
1012
|
+
if (isPlainObject(frame) && frame.type === 'baseline' && !localSeen) {
|
|
1013
|
+
localSeen = true
|
|
1014
|
+
// The baseline opens the stream; the buffered remote output follows.
|
|
1015
|
+
const out = [frame, ...buffered]
|
|
1016
|
+
buffered = []
|
|
1017
|
+
return out
|
|
1018
|
+
}
|
|
1019
|
+
return [frame]
|
|
1020
|
+
},
|
|
1021
|
+
|
|
1022
|
+
onRemote(frame: unknown): unknown[] {
|
|
1023
|
+
if (!isPlainObject(frame)) {
|
|
1024
|
+
onDiagnostic?.('dropped a non-object session/control frame from the relay')
|
|
1025
|
+
return []
|
|
1026
|
+
}
|
|
1027
|
+
const converted = convert(frame)
|
|
1028
|
+
if (!localSeen) {
|
|
1029
|
+
buffered.push(...converted)
|
|
1030
|
+
return []
|
|
1031
|
+
}
|
|
1032
|
+
return converted
|
|
1033
|
+
},
|
|
1034
|
+
|
|
1035
|
+
onRemoteDown(): unknown[] {
|
|
1036
|
+
return []
|
|
1037
|
+
},
|
|
1038
|
+
|
|
1039
|
+
onRemoteGone(): unknown[] {
|
|
1040
|
+
// Buffered pre-baseline remote output dies with the remote leg; the UI
|
|
1041
|
+
// never saw it.
|
|
1042
|
+
buffered = []
|
|
1043
|
+
return []
|
|
1044
|
+
},
|
|
1045
|
+
|
|
1046
|
+
onServerRenamed(): unknown[] {
|
|
1047
|
+
return []
|
|
1048
|
+
},
|
|
1049
|
+
|
|
1050
|
+
setStatus(_annotation: MergerAnnotation): void {},
|
|
1051
|
+
|
|
1052
|
+
onStatusChanged(): unknown[] {
|
|
1053
|
+
return []
|
|
1054
|
+
},
|
|
1055
|
+
|
|
1056
|
+
setCurrentSession(_sessionId: string | undefined): unknown[] {
|
|
1057
|
+
return []
|
|
1058
|
+
},
|
|
1059
|
+
|
|
1060
|
+
get hasPendingHide(): boolean {
|
|
1061
|
+
return false
|
|
1062
|
+
},
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Merge one `session/list` result pair (first page only — the caller skips
|
|
1068
|
+
* paged requests). Local items keep their order and their pagination fields;
|
|
1069
|
+
* the remote items are appended with virtualized session ids — `sessionId`
|
|
1070
|
+
* AND `parentSessionId` (CP4): the fork link must point at the VIRTUAL parent
|
|
1071
|
+
* id the UI knows, or the fork would sort beside a parent id that exists in
|
|
1072
|
+
* no list the UI holds — and a T52-fix rewrite of the row's own projections
|
|
1073
|
+
* block: a list row carries `{kind:'cached'|'sequenced', asOfSeq, values}`
|
|
1074
|
+
* (RT dsh-api-session-controller lib/typert.remote-client.js:381-428) and the
|
|
1075
|
+
* client face applies it PER SESSION (lib/client.js:2633 → applyListBlock
|
|
1076
|
+
* :2842-2855) — a `sequenced` modelSelection lands under higher-seq-wins
|
|
1077
|
+
* (lib/client.js:986-995, `seq <= row.seq` rejects), and the control stream's
|
|
1078
|
+
* REWRITTEN frame rides that projection's OWN seq, so an original-provider
|
|
1079
|
+
* value left in a row would poison the store forever: the remote session's
|
|
1080
|
+
* dropdown would show the raw `provider/model` fallback with no check mark.
|
|
1081
|
+
* The rewrite is UNCONDITIONAL (T52-fix3): first-write-wins per seq forbids
|
|
1082
|
+
* a catalog gate — a row rewritten while the catalog was missing would
|
|
1083
|
+
* occupy the seq forever. A missing or malformed remote result means "remote
|
|
1084
|
+
* said nothing" — the local result passes back untouched.
|
|
1085
|
+
*/
|
|
1086
|
+
export function mergeSessionList(localResult: unknown, remoteResult: unknown, serverId: string): unknown {
|
|
1087
|
+
if (!isPlainObject(localResult)) return localResult
|
|
1088
|
+
if (!isPlainObject(remoteResult) || !Array.isArray(remoteResult.items)) return localResult
|
|
1089
|
+
const localItems = Array.isArray(localResult.items) ? localResult.items : []
|
|
1090
|
+
const virtualize = (id: string): string => toVirtual(serverId, id)
|
|
1091
|
+
const remoteItems = remoteResult.items.map((item) => {
|
|
1092
|
+
if (!isPlainObject(item) || typeof item.sessionId !== 'string') return item
|
|
1093
|
+
const out: Record<string, unknown> = { ...item, sessionId: virtualize(item.sessionId) }
|
|
1094
|
+
if (typeof item.parentSessionId === 'string') out.parentSessionId = virtualize(item.parentSessionId)
|
|
1095
|
+
if (
|
|
1096
|
+
isPlainObject(item.projections) &&
|
|
1097
|
+
isPlainObject(item.projections.values) &&
|
|
1098
|
+
Object.hasOwn(item.projections.values, 'modelSelection')
|
|
1099
|
+
) {
|
|
1100
|
+
const values: Record<string, unknown> = { ...item.projections.values }
|
|
1101
|
+
values.modelSelection = virtualizeModelSelectionValue(values.modelSelection, serverId)
|
|
1102
|
+
out.projections = { ...item.projections, values }
|
|
1103
|
+
}
|
|
1104
|
+
return out
|
|
1105
|
+
})
|
|
1106
|
+
return { ...localResult, items: [...localItems, ...remoteItems] }
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Fold the relay's `session/modelCatalog` answer into the local one (T52).
|
|
1111
|
+
* The result shape is RT dsh-api-session-controller
|
|
1112
|
+
* `session_modelCatalog_result$schema` (lib/typert.remote-client.js:489-518):
|
|
1113
|
+
* `{default, routableProviders, groups, failures}` — provider group ids and
|
|
1114
|
+
* display names only, no session data anywhere, so no output filter guards it
|
|
1115
|
+
* server-side and nothing here needs narrowing.
|
|
1116
|
+
*
|
|
1117
|
+
* - server groups are APPENDED after the local ones (the dropdown sorts
|
|
1118
|
+
* `deepseek-account` / `deepseek-official` first and keeps the rest in
|
|
1119
|
+
* order, RT dsh-client-ui-model-selection lib/client.js:510 — appended
|
|
1120
|
+
* groups render last); each group id becomes the virtual group id
|
|
1121
|
+
* `zr~<serverId>~<original>` and its name is prefixed
|
|
1122
|
+
* `${serverName} · ${name}` — the same workspace title discipline the
|
|
1123
|
+
* merger uses;
|
|
1124
|
+
* - `default` and `failures` stay LOCAL untouched: the default drives what a
|
|
1125
|
+
* blank LOCAL session would run (a remote session's current model comes
|
|
1126
|
+
* from its projection instead), and the UI renders every failure as a
|
|
1127
|
+
* prominent warning row whose Retry reloads the WHOLE catalog
|
|
1128
|
+
* (lib/client.js:816-827) — a server-side group failure is not retryable
|
|
1129
|
+
* from here and would put a permanent alarm on every dropdown, so it does
|
|
1130
|
+
* not travel (dsh-vision-router's settings read the same result and shows
|
|
1131
|
+
* an error state exactly when groups are empty AND failures exist,
|
|
1132
|
+
* lib/client.js:635-647 — dropping remote failures keeps that honest).
|
|
1133
|
+
* KNOWN BOUNDARY (pre-T52 behavior, unchanged): a NEW server session's
|
|
1134
|
+
* projection `next` is null until its first selection or turn, so the UI
|
|
1135
|
+
* shows `catalog.value.default` — the LOCAL default — as that session's
|
|
1136
|
+
* current model (RT dsh-client-ui-model-selection lib/client.js:316,
|
|
1137
|
+
* `projected?.next ?? catalog.value?.default`), which may differ from what
|
|
1138
|
+
* the server would actually run until its first selection lands.
|
|
1139
|
+
* - `routableProviders` mirrors the group ids by construction host-side
|
|
1140
|
+
* (RT dsh-api-session-controller lib/index.js:544 — literally
|
|
1141
|
+
* `groups.map(g => g.id)`); no client UI reads it (the dropdown's
|
|
1142
|
+
* routable verdict comes from the groups themselves, lib/client.js:334),
|
|
1143
|
+
* so appending the virtual ids is a consistency move: the merged catalog
|
|
1144
|
+
* keeps the invariant the Host maintains, whatever reads it next.
|
|
1145
|
+
*
|
|
1146
|
+
* A malformed local or remote value means "one side said nothing" — the
|
|
1147
|
+
* local result passes back untouched, like {@link mergeSessionList}.
|
|
1148
|
+
*/
|
|
1149
|
+
export function mergeModelCatalogs(localValue: unknown, remoteValue: unknown, identity: MergerIdentity): unknown {
|
|
1150
|
+
if (!isPlainObject(localValue)) return localValue
|
|
1151
|
+
if (!isPlainObject(remoteValue) || !Array.isArray(remoteValue.groups)) return localValue
|
|
1152
|
+
const localGroups = Array.isArray(localValue.groups) ? localValue.groups : []
|
|
1153
|
+
const virtualGroups: unknown[] = []
|
|
1154
|
+
const virtualIds: string[] = []
|
|
1155
|
+
for (const group of remoteValue.groups) {
|
|
1156
|
+
if (!isPlainObject(group) || typeof group.id !== 'string' || group.id === '') continue
|
|
1157
|
+
const id = toVirtual(identity.serverId, group.id)
|
|
1158
|
+
virtualIds.push(id)
|
|
1159
|
+
virtualGroups.push({
|
|
1160
|
+
...group,
|
|
1161
|
+
id,
|
|
1162
|
+
...(typeof group.name === 'string' ? { name: `${identity.serverName} · ${group.name}` } : {}),
|
|
1163
|
+
})
|
|
1164
|
+
}
|
|
1165
|
+
if (virtualGroups.length === 0) return localValue
|
|
1166
|
+
return {
|
|
1167
|
+
...localValue,
|
|
1168
|
+
groups: [...localGroups, ...virtualGroups],
|
|
1169
|
+
routableProviders: [...stringList(localValue.routableProviders), ...virtualIds],
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Virtualize the provider ids inside one `modelSelection` projection value —
|
|
1175
|
+
* `{lastUsed: {provider, model, reasoningEffort?} | null, next: …}` (RT
|
|
1176
|
+
* dsh-api-session-controller lib/types/model-selection-projection.js:12-15,
|
|
1177
|
+
* wire shapes at lib/typert.remote-client.js:83-94). The UI resolves the
|
|
1178
|
+
* current model's display name by matching `(provider, model)` against the
|
|
1179
|
+
* catalog groups (dsh-client-ui-model-selection lib/client.js:334, 520); for
|
|
1180
|
+
* a remote session the provider is a SERVER group id, so it must become the
|
|
1181
|
+
* virtual group id to be found in the merged catalog — and while the relay
|
|
1182
|
+
* serves, the merged catalog always carries it, so the composer trigger
|
|
1183
|
+
* shows the model NAME instead of the raw `provider/model` fallback string
|
|
1184
|
+
* (lib/client.js:700).
|
|
1185
|
+
*
|
|
1186
|
+
* UNCONDITIONAL by decision (T52-fix3, reverting T52-fix2's catalog gate):
|
|
1187
|
+
* the host's projection store is first-write-wins per sequence number
|
|
1188
|
+
* (lib/client.js:986-994), so whether a value is rewritten may not depend on
|
|
1189
|
+
* WHEN the catalog happened to arrive — an original written pre-catalog
|
|
1190
|
+
* occupies the seq and the later virtual value is dropped, leaving the
|
|
1191
|
+
* server group unchecked and the next reasoning-effort submit refused.
|
|
1192
|
+
* Accepted cost: a provider the server's own catalog does not list renders
|
|
1193
|
+
* the `zr~<serverId>~provider/model` fallback string. Nulls and malformed
|
|
1194
|
+
* entries pass through untouched; `model` and `reasoningEffort` are
|
|
1195
|
+
* group-agnostic ids and stay as they are.
|
|
1196
|
+
*/
|
|
1197
|
+
export function virtualizeModelSelectionValue(value: unknown, serverId: string): unknown {
|
|
1198
|
+
if (!isPlainObject(value)) return value
|
|
1199
|
+
const out: Record<string, unknown> = { ...value }
|
|
1200
|
+
for (const key of ['lastUsed', 'next'] as const) {
|
|
1201
|
+
const selection = value[key]
|
|
1202
|
+
if (isPlainObject(selection) && typeof selection.provider === 'string') {
|
|
1203
|
+
out[key] = { ...selection, provider: toVirtual(serverId, selection.provider) }
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
return out
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* T63: the virtual session ids a set of FORWARDED workspace frames carries —
|
|
1211
|
+
* the `sessionIds` of `upsert` records and of baseline `value.items`. The
|
|
1212
|
+
* workspace route feeds these to the summary sync, which announces list rows
|
|
1213
|
+
* for ids the UI has no summary for yet (the sidebar drops a group member
|
|
1214
|
+
* without one — RT dsh-client-ui-workspace orderByRecency / groupByWorkspace).
|
|
1215
|
+
* Reading the FORWARDED output (not the merger's raw state) is the point:
|
|
1216
|
+
* T56 already withheld empty groups and T58 already dropped hidden sessions,
|
|
1217
|
+
* so a hidden closed-remote session never reaches here and is never
|
|
1218
|
+
* announced. Only virtual ids count — local baseline content rides these
|
|
1219
|
+
* frames verbatim and its rows already have summaries.
|
|
1220
|
+
*/
|
|
1221
|
+
export function virtualSessionIdsInWorkspaceFrames(frames: readonly unknown[]): string[] {
|
|
1222
|
+
const out = new Set<string>()
|
|
1223
|
+
const collect = (record: unknown): void => {
|
|
1224
|
+
if (!isPlainObject(record) || !Array.isArray(record.sessionIds)) return
|
|
1225
|
+
for (const id of record.sessionIds) {
|
|
1226
|
+
if (typeof id === 'string' && isVirtual(id)) out.add(id)
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
for (const frame of frames) {
|
|
1230
|
+
if (!isPlainObject(frame)) continue
|
|
1231
|
+
if (frame.type === 'upsert') collect(frame.workspace)
|
|
1232
|
+
if (frame.type === 'baseline') {
|
|
1233
|
+
const value = isPlainObject(frame.value) ? frame.value : undefined
|
|
1234
|
+
if (value !== undefined && Array.isArray(value.items)) for (const item of value.items) collect(item)
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
return [...out]
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* T63: the synthesized `api-session/added` emit frames for one relay
|
|
1242
|
+
* `session/list` answer — one `{type:'emit', event:'api-session/added',
|
|
1243
|
+
* args:[row]}` frame per row. This is the host's own mechanism for adding a
|
|
1244
|
+
* list row without a re-pull (RT dsh-api-session-controller: the client face
|
|
1245
|
+
* subscribes `api-session/added` → handleSessionAdded → mergeSummary →
|
|
1246
|
+
* applyMutation's upsert, which ADDS an unknown row and fills an existing
|
|
1247
|
+
* one — idempotent), and the emit frame shape is the client face's
|
|
1248
|
+
* exact-keys `{type, event, args}` with args a JSON array
|
|
1249
|
+
* (dsh-api-gateway lib/client.js parseRemoteEventFrame) — the same shape the
|
|
1250
|
+
* T52 catalog-refresh frame uses. The rows are virtualized by
|
|
1251
|
+
* {@link mergeSessionList} itself, so a synthesized row carries exactly what
|
|
1252
|
+
* the merged `session/list` route would have answered: the session id AND
|
|
1253
|
+
* the fork parent virtualized, and the projections' modelSelection providers
|
|
1254
|
+
* rewritten (a row's `sequenced` block must not poison the projection store
|
|
1255
|
+
* against the control stream's rewritten frames — T52-fix3). A malformed
|
|
1256
|
+
* remote result means "the server said nothing" — no frames.
|
|
1257
|
+
*/
|
|
1258
|
+
export function sessionSummaryAddedFrames(remoteResult: unknown, serverId: string): unknown[] {
|
|
1259
|
+
if (!isPlainObject(remoteResult) || !Array.isArray(remoteResult.items)) return []
|
|
1260
|
+
const merged = mergeSessionList({ items: [] }, remoteResult, serverId)
|
|
1261
|
+
if (!isPlainObject(merged) || !Array.isArray(merged.items)) return []
|
|
1262
|
+
const frames: unknown[] = []
|
|
1263
|
+
for (const row of merged.items) {
|
|
1264
|
+
// mergeSessionList passes a malformed row through as-is; the UI's
|
|
1265
|
+
// applyMutation would file it under `byId[undefined]`, so only a
|
|
1266
|
+
// well-formed row (plain object, string sessionId — the shape
|
|
1267
|
+
// handleSessionAdded's mergeSummary keys on) becomes a frame.
|
|
1268
|
+
if (!isPlainObject(row) || typeof row.sessionId !== 'string' || row.sessionId === '') continue
|
|
1269
|
+
frames.push({ type: 'emit', event: 'api-session/added', args: [row] })
|
|
1270
|
+
}
|
|
1271
|
+
return frames
|
|
1272
|
+
}
|