@saluzi/saluzi-edu 0.2.61 → 0.2.62
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/dist/cli.js +198 -110
- package/dist/parade/web/menu.html +71 -23
- package/package.json +1 -1
- package/packages/remote-control-server/src/__tests__/models-state-snapshot.test.ts +216 -0
- package/packages/remote-control-server/src/routes/web/sessions.ts +33 -0
- package/packages/remote-control-server/src/services/transport.ts +12 -0
- package/packages/remote-control-server/src/transport/event-bus.ts +10 -0
|
@@ -14,14 +14,23 @@
|
|
|
14
14
|
cursor, so the menu can extend beyond the parade window.
|
|
15
15
|
|
|
16
16
|
Contract with the electron main script:
|
|
17
|
-
- Window
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
- Window: one PRE-WARMED frameless transparent popup, created hidden at
|
|
18
|
+
startup and REUSED for every right-click (hide/show, never recreated —
|
|
19
|
+
recreating a renderer process per open read as a laggy menu). Main
|
|
20
|
+
re-anchors it via setBounds at the OS cursor and sizes it
|
|
21
|
+
MENU_W x MENU_H design px x unitScale — the constants in paradeWindow.ts
|
|
22
|
+
MUST match the locked layout below.
|
|
23
|
+
- State: pushed per open over IPC parade-menu-state {mode, rot}; the page
|
|
24
|
+
applies it and acks via paradeMenu.ready(), then main shows the window
|
|
25
|
+
(no stale highlight). The ?mode/&rot/&zoom=<dpiScale> query params are
|
|
26
|
+
only the initial-load fallback / browser-debugging path. zoom is applied
|
|
27
|
+
identically to index.html (physical px compensation on
|
|
21
28
|
--force-device-scale-factor=1 Win11; multiply window size by unitScale).
|
|
22
29
|
- Selections: window.paradeMenu.action({type:'view', value}) or
|
|
23
30
|
({type:'rotation'}) → IPC parade-menu-action → relayed to the parade
|
|
24
31
|
window renderer, which stays the single source of truth for mode state.
|
|
32
|
+
Escape dismisses via window.paradeMenu.dismiss() (hide, not close —
|
|
33
|
+
the window is reused).
|
|
25
34
|
-->
|
|
26
35
|
<style>
|
|
27
36
|
:root {
|
|
@@ -124,35 +133,67 @@
|
|
|
124
133
|
<div class="ctx-item" id="ctx-mode">Rotation: Dynamic</div>
|
|
125
134
|
</div>
|
|
126
135
|
<script>
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
var
|
|
136
|
-
if (zoom > 1) document.documentElement.style.zoom = String(zoom)
|
|
137
|
-
|
|
138
|
-
// Mark the active display mode radio item.
|
|
139
|
-
var mode = q.get('mode') || ''
|
|
136
|
+
// ---- State application ----
|
|
137
|
+
// The electron main process pre-warms this page and reuses its window for
|
|
138
|
+
// every right-click: each open pushes the parade window's current state via
|
|
139
|
+
// the parade-menu-state IPC; the query params below are only the initial
|
|
140
|
+
// fallback / browser-debugging path. applyState() must therefore REMOVE the
|
|
141
|
+
// previous open's active marks, not just add new ones.
|
|
142
|
+
function applyState(state) {
|
|
143
|
+
state = (state && typeof state === 'object') ? state : {}
|
|
144
|
+
var mode = String(state.mode || '')
|
|
140
145
|
var views = document.querySelectorAll('.ctx-view')
|
|
141
146
|
for (let i = 0; i < views.length; i++) {
|
|
142
147
|
if (views[i].getAttribute('data-view') === mode) {
|
|
143
148
|
views[i].classList.add('active')
|
|
149
|
+
} else {
|
|
150
|
+
views[i].classList.remove('active')
|
|
144
151
|
}
|
|
145
152
|
}
|
|
146
|
-
|
|
147
|
-
// Rotation label.
|
|
148
|
-
var rot = q.get('rot') === 'static' ? 'static' : 'dynamic'
|
|
153
|
+
var rot = state.rot === 'static' ? 'static' : 'dynamic'
|
|
149
154
|
var modeEl = document.getElementById('ctx-mode')
|
|
150
155
|
if (modeEl) {
|
|
151
156
|
modeEl.textContent = 'Rotation: ' + (rot === 'dynamic' ? 'Dynamic' : 'Static')
|
|
152
157
|
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The page itself is reused across opens, so the load-time #menu animation
|
|
161
|
+
// would only ever play once — replay it on every state push (i.e. every open).
|
|
162
|
+
function replayOpenAnimation() {
|
|
163
|
+
var menuEl = document.getElementById('menu')
|
|
164
|
+
if (!menuEl) return
|
|
165
|
+
menuEl.style.animation = 'none'
|
|
166
|
+
void menuEl.offsetWidth // force reflow so the animation restarts
|
|
167
|
+
menuEl.style.animation = ''
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Initial state via query string (fallback / browser debugging).
|
|
171
|
+
;(function () {
|
|
172
|
+
var q = new URLSearchParams(window.location.search)
|
|
173
|
+
|
|
174
|
+
// DPI compensation — identical mechanism to index.html/cube.js: the main
|
|
175
|
+
// process forces 1 CSS px = 1 physical px on Win11, then we CSS-zoom the
|
|
176
|
+
// content by the OS DPI factor so physical size matches intent. Applied at
|
|
177
|
+
// parse time; the window was sized as design px x unitScale by main.
|
|
178
|
+
var zoom = parseFloat(q.get('zoom')) || 0
|
|
179
|
+
if (zoom > 1) document.documentElement.style.zoom = String(zoom)
|
|
180
|
+
|
|
181
|
+
applyState({ mode: q.get('mode') || '', rot: q.get('rot') || '' })
|
|
153
182
|
})()
|
|
154
183
|
|
|
155
|
-
//
|
|
184
|
+
// Per-open state push: apply, replay the open animation, then ack so main
|
|
185
|
+
// shows the window with the highlight already up to date.
|
|
186
|
+
if (window.paradeMenu && typeof window.paradeMenu.onState === 'function') {
|
|
187
|
+
window.paradeMenu.onState(function (state) {
|
|
188
|
+
applyState(state)
|
|
189
|
+
replayOpenAnimation()
|
|
190
|
+
if (typeof window.paradeMenu.ready === 'function') {
|
|
191
|
+
window.paradeMenu.ready()
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Report a selection to the electron main process, which hides the popup and
|
|
156
197
|
// relays the action to the parade window renderer.
|
|
157
198
|
function reportAction(action) {
|
|
158
199
|
if (window.paradeMenu && typeof window.paradeMenu.action === 'function') {
|
|
@@ -178,10 +219,17 @@ if (modeEl) {
|
|
|
178
219
|
})
|
|
179
220
|
}
|
|
180
221
|
|
|
181
|
-
// Escape dismisses (native-menu parity).
|
|
222
|
+
// Escape dismisses (native-menu parity). The popup window is reused, so the
|
|
223
|
+
// page must NOT window.close() itself (that would destroy the prewarmed
|
|
224
|
+
// renderer) — it asks main to hide it instead. window.close() stays as the
|
|
225
|
+
// browser-debugging fallback where the bridge is missing.
|
|
182
226
|
document.addEventListener('keydown', function (e) {
|
|
183
227
|
if (e.key === 'Escape') {
|
|
184
|
-
|
|
228
|
+
if (window.paradeMenu && typeof window.paradeMenu.dismiss === 'function') {
|
|
229
|
+
window.paradeMenu.dismiss()
|
|
230
|
+
} else {
|
|
231
|
+
try { window.close() } catch (_) { /* ignore */ }
|
|
232
|
+
}
|
|
185
233
|
}
|
|
186
234
|
})
|
|
187
235
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: model selector gray after re-entering a session.
|
|
3
|
+
*
|
|
4
|
+
* models_state is a one-shot snapshot — the worker sends it only once per
|
|
5
|
+
* bridge connect, and the web UI restores it from the last models_state
|
|
6
|
+
* event in GET /history. Anything that wipes or evicts the event log
|
|
7
|
+
* (/clear, /new, window eviction) used to make the selector permanently
|
|
8
|
+
* gray until the worker reconnected. The bus now keeps the latest snapshot
|
|
9
|
+
* out-of-band: /clear re-seeds it, /history merges it when the log lacks
|
|
10
|
+
* the event. Covers both worker write paths:
|
|
11
|
+
* - v1 session-ingress (HybridTransport POST → ingestBridgeMessage)
|
|
12
|
+
* - v2 CCR worker events (POST /v1/code/sessions/:id/worker/events)
|
|
13
|
+
*/
|
|
14
|
+
import { describe, test, expect, beforeEach, mock } from 'bun:test'
|
|
15
|
+
import { mockConfigModule } from './helpers/mock-config'
|
|
16
|
+
|
|
17
|
+
mock.module('../config', () => mockConfigModule({ pollTimeout: 1 }))
|
|
18
|
+
|
|
19
|
+
import { Hono } from 'hono'
|
|
20
|
+
import { storeReset } from '../store'
|
|
21
|
+
import {
|
|
22
|
+
getAllEventBuses,
|
|
23
|
+
removeEventBus,
|
|
24
|
+
getEventBus,
|
|
25
|
+
} from '../transport/event-bus'
|
|
26
|
+
|
|
27
|
+
import v1SessionIngress from '../routes/v1/session-ingress'
|
|
28
|
+
import v2WorkerEvents from '../routes/v2/worker-events'
|
|
29
|
+
import webSessions from '../routes/web/sessions'
|
|
30
|
+
import { issueToken } from '../auth/token'
|
|
31
|
+
import { deletePersistedEvents } from '../services/event-persistence'
|
|
32
|
+
import { publishSessionEvent } from '../services/transport'
|
|
33
|
+
|
|
34
|
+
function createApp() {
|
|
35
|
+
const app = new Hono()
|
|
36
|
+
app.route('/v2/session_ingress', v1SessionIngress)
|
|
37
|
+
app.route('/v1/code/sessions', v2WorkerEvents)
|
|
38
|
+
app.route('/web', webSessions)
|
|
39
|
+
return app
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const AUTH_HEADERS = {
|
|
43
|
+
Authorization: 'Bearer test-api-key',
|
|
44
|
+
'X-Username': 'testuser',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function auth(user: string): string {
|
|
48
|
+
return `Bearer ${issueToken(user).token}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const MODELS_STATE_MSG = {
|
|
52
|
+
type: 'models_state',
|
|
53
|
+
models_state: {
|
|
54
|
+
availableModels: [
|
|
55
|
+
{ modelId: 'firstParty', name: 'First Party' },
|
|
56
|
+
{ modelId: 'firstParty:opus', name: 'Opus' },
|
|
57
|
+
],
|
|
58
|
+
currentModelId: 'firstParty',
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('models_state snapshot survives history loss (gray selector regression)', () => {
|
|
63
|
+
let app: Hono
|
|
64
|
+
let sessionId: string
|
|
65
|
+
|
|
66
|
+
beforeEach(async () => {
|
|
67
|
+
storeReset()
|
|
68
|
+
for (const [key] of getAllEventBuses()) {
|
|
69
|
+
removeEventBus(key)
|
|
70
|
+
}
|
|
71
|
+
app = createApp()
|
|
72
|
+
|
|
73
|
+
const res = await app.request('/web/sessions', {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
Authorization: auth('user-1'),
|
|
77
|
+
'Content-Type': 'application/json',
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({}),
|
|
80
|
+
})
|
|
81
|
+
const body = (await res.json()) as { id: string }
|
|
82
|
+
sessionId = body.id
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
async function fetchHistory(): Promise<
|
|
86
|
+
Array<{ id: string; type: string; payload: Record<string, unknown> }>
|
|
87
|
+
> {
|
|
88
|
+
const res = await app.request(`/web/sessions/${sessionId}/history`, {
|
|
89
|
+
headers: { Authorization: auth('user-1') },
|
|
90
|
+
})
|
|
91
|
+
expect(res.status).toBe(200)
|
|
92
|
+
const body = (await res.json()) as {
|
|
93
|
+
events: Array<Record<string, unknown>>
|
|
94
|
+
}
|
|
95
|
+
return body.events as Array<{
|
|
96
|
+
id: string
|
|
97
|
+
type: string
|
|
98
|
+
payload: Record<string, unknown>
|
|
99
|
+
}>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseModelsState(
|
|
103
|
+
events: Array<{
|
|
104
|
+
id: string
|
|
105
|
+
type: string
|
|
106
|
+
payload: Record<string, unknown>
|
|
107
|
+
}>,
|
|
108
|
+
): { availableModels: unknown[]; currentModelId: string } | null {
|
|
109
|
+
// Same parse the web adapter's loadHistory performs.
|
|
110
|
+
for (const event of [...events].reverse()) {
|
|
111
|
+
if (event.type !== 'models_state') continue
|
|
112
|
+
const raw = event.payload.raw
|
|
113
|
+
const state = (
|
|
114
|
+
raw && typeof raw === 'object'
|
|
115
|
+
? (raw as Record<string, unknown>).models_state
|
|
116
|
+
: event.payload.models_state
|
|
117
|
+
) as { availableModels: unknown[]; currentModelId: string } | undefined
|
|
118
|
+
if (
|
|
119
|
+
state &&
|
|
120
|
+
Array.isArray(state.availableModels) &&
|
|
121
|
+
typeof state.currentModelId === 'string'
|
|
122
|
+
) {
|
|
123
|
+
return state
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
test('v1 session-ingress POST path reaches /history', async () => {
|
|
130
|
+
const ingestRes = await app.request(
|
|
131
|
+
`/v2/session_ingress/session/${sessionId}/events`,
|
|
132
|
+
{
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
135
|
+
body: JSON.stringify({ events: [MODELS_STATE_MSG] }),
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
expect(ingestRes.status).toBe(200)
|
|
139
|
+
|
|
140
|
+
const state = parseModelsState(await fetchHistory())
|
|
141
|
+
expect(state).not.toBeNull()
|
|
142
|
+
expect(state?.currentModelId).toBe('firstParty')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('v2 worker events POST path (payload envelope) reaches /history', async () => {
|
|
146
|
+
const ingestRes = await app.request(
|
|
147
|
+
`/v1/code/sessions/${sessionId}/worker/events`,
|
|
148
|
+
{
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
151
|
+
body: JSON.stringify([
|
|
152
|
+
{ payload: { ...MODELS_STATE_MSG, uuid: 'u1' } },
|
|
153
|
+
]),
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
expect(ingestRes.status).toBe(200)
|
|
157
|
+
|
|
158
|
+
const state = parseModelsState(await fetchHistory())
|
|
159
|
+
expect(state).not.toBeNull()
|
|
160
|
+
expect(state?.availableModels).toHaveLength(2)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('POST /clear re-seeds models_state so re-entry still restores the selector', async () => {
|
|
164
|
+
await app.request(`/v2/session_ingress/session/${sessionId}/events`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
167
|
+
body: JSON.stringify({ events: [MODELS_STATE_MSG] }),
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const clearRes = await app.request(`/web/sessions/${sessionId}/clear`, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { Authorization: auth('user-1') },
|
|
173
|
+
})
|
|
174
|
+
expect(clearRes.status).toBe(200)
|
|
175
|
+
|
|
176
|
+
const state = parseModelsState(await fetchHistory())
|
|
177
|
+
expect(state).not.toBeNull()
|
|
178
|
+
expect(state?.currentModelId).toBe('firstParty')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('/history merges the bus snapshot when the event log lost the entry', async () => {
|
|
182
|
+
await app.request(`/v2/session_ingress/session/${sessionId}/events`, {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
|
|
185
|
+
body: JSON.stringify({ events: [MODELS_STATE_MSG] }),
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// Simulate pure log loss (server older than the re-seed, eviction, …):
|
|
189
|
+
// wipe events but keep the bus alive — the snapshot must cover it.
|
|
190
|
+
getEventBus(sessionId).clearHistory()
|
|
191
|
+
deletePersistedEvents(sessionId)
|
|
192
|
+
|
|
193
|
+
const state = parseModelsState(await fetchHistory())
|
|
194
|
+
expect(state).not.toBeNull()
|
|
195
|
+
expect(state?.availableModels).toHaveLength(2)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('snapshot updates to the latest models_state (set_model re-report)', async () => {
|
|
199
|
+
publishSessionEvent(sessionId, 'models_state', MODELS_STATE_MSG, 'inbound')
|
|
200
|
+
publishSessionEvent(
|
|
201
|
+
sessionId,
|
|
202
|
+
'models_state',
|
|
203
|
+
{
|
|
204
|
+
type: 'models_state',
|
|
205
|
+
models_state: {
|
|
206
|
+
...MODELS_STATE_MSG.models_state,
|
|
207
|
+
currentModelId: 'firstParty:opus',
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
'inbound',
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const state = parseModelsState(await fetchHistory())
|
|
214
|
+
expect(state?.currentModelId).toBe('firstParty:opus')
|
|
215
|
+
})
|
|
216
|
+
})
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import { createWorkItem, stopWork } from '../../services/work-dispatch'
|
|
35
35
|
import { createSSEStream } from '../../transport/sse-writer'
|
|
36
36
|
import { getEventBus } from '../../transport/event-bus'
|
|
37
|
+
import { publishSessionEvent } from '../../services/transport'
|
|
37
38
|
|
|
38
39
|
const app = new Hono()
|
|
39
40
|
|
|
@@ -286,6 +287,26 @@ app.get('/sessions/:id/history', sessionAuth, async c => {
|
|
|
286
287
|
const events = [...byId.values()].sort(
|
|
287
288
|
(a, b) => a.createdAt - b.createdAt || a.seqNum - b.seqNum,
|
|
288
289
|
)
|
|
290
|
+
// models_state fallback: the worker reports the model list once per bridge
|
|
291
|
+
// connect, so the event log can legitimately lack it (/clear before the
|
|
292
|
+
// re-seed existed, event-window eviction, pre-persistence servers). The
|
|
293
|
+
// bus keeps the latest snapshot out-of-band — merge it here so the web UI
|
|
294
|
+
// can restore the model selector on every re-entry.
|
|
295
|
+
if (!events.some(e => e.type === 'models_state')) {
|
|
296
|
+
const snapshot = getEventBus(sessionId).modelsStateSnapshot
|
|
297
|
+
if (snapshot) {
|
|
298
|
+
const lastCreated = events[events.length - 1]?.createdAt ?? Date.now()
|
|
299
|
+
events.push({
|
|
300
|
+
id: `models_state_snapshot_${sessionId}`,
|
|
301
|
+
sessionId,
|
|
302
|
+
seqNum: 0,
|
|
303
|
+
type: 'models_state',
|
|
304
|
+
payload: { models_state: snapshot },
|
|
305
|
+
direction: 'inbound',
|
|
306
|
+
createdAt: lastCreated + 1,
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
}
|
|
289
310
|
return c.json({ events }, 200)
|
|
290
311
|
})
|
|
291
312
|
|
|
@@ -344,6 +365,18 @@ app.post('/sessions/:id/clear', sessionAuth, async c => {
|
|
|
344
365
|
},
|
|
345
366
|
{ transient: true },
|
|
346
367
|
)
|
|
368
|
+
// Re-seed models_state from the bus snapshot. The worker only reports
|
|
369
|
+
// models_state once per bridge connect, so a plain history wipe would make
|
|
370
|
+
// the model selector permanently gray after re-entering the session (and
|
|
371
|
+
// /model would report "未提供模型选择能力") until the worker reconnects.
|
|
372
|
+
if (bus.modelsStateSnapshot) {
|
|
373
|
+
publishSessionEvent(
|
|
374
|
+
sessionId,
|
|
375
|
+
'models_state',
|
|
376
|
+
{ type: 'models_state', models_state: bus.modelsStateSnapshot },
|
|
377
|
+
'inbound',
|
|
378
|
+
)
|
|
379
|
+
}
|
|
347
380
|
log(`[RCS] Cleared conversation history for session ${sessionId}`)
|
|
348
381
|
return c.json({ status: 'ok' }, 200)
|
|
349
382
|
})
|
|
@@ -169,6 +169,18 @@ export function publishSessionEvent(
|
|
|
169
169
|
const bus = getEventBus(sessionId)
|
|
170
170
|
const eventId = randomUUID()
|
|
171
171
|
|
|
172
|
+
// models_state is a one-shot snapshot from the worker (sent on bridge
|
|
173
|
+
// connect, never re-sent during the worker's lifetime). Keep the latest
|
|
174
|
+
// payload on the bus so /clear re-seeding and /history fallback can
|
|
175
|
+
// restore the web UI's model selector even after the event log lost the
|
|
176
|
+
// original entry.
|
|
177
|
+
if (type === 'models_state' && payload && typeof payload === 'object') {
|
|
178
|
+
const state = (payload as Record<string, unknown>).models_state
|
|
179
|
+
if (state && typeof state === 'object') {
|
|
180
|
+
bus.modelsStateSnapshot = state as Record<string, unknown>
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
172
184
|
const normalized = normalizePayload(type, payload)
|
|
173
185
|
const transient = TRANSIENT_EVENT_TYPES.has(type)
|
|
174
186
|
|
|
@@ -30,6 +30,16 @@ export class EventBus {
|
|
|
30
30
|
MAX_SEEN_UUIDS_PER_BUS,
|
|
31
31
|
)
|
|
32
32
|
private seenUuidWriteIdx = 0
|
|
33
|
+
/**
|
|
34
|
+
* Latest models_state payload for the session, kept OUT of the retained
|
|
35
|
+
* event window. The worker reports models_state only once per bridge
|
|
36
|
+
* connect, while the event log is mutable (/clear wipes it, the bounded
|
|
37
|
+
* window evicts old entries) — without this snapshot any log loss makes
|
|
38
|
+
* the web UI's model selector permanently gray until the worker
|
|
39
|
+
* reconnects. Lives on the bus so it is dropped when the session is
|
|
40
|
+
* deleted (removeEventBus) and survives clearHistory().
|
|
41
|
+
*/
|
|
42
|
+
modelsStateSnapshot: Record<string, unknown> | null = null
|
|
33
43
|
|
|
34
44
|
subscribe(callback: Subscriber): () => void {
|
|
35
45
|
this.subscribers.add(callback)
|