@adea-ai/spatial 0.112.0 → 0.113.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.
@@ -0,0 +1,679 @@
1
+ import { boundedString, isPlainRecord, strictRecord } from './record'
2
+ import type { OriginAllowlist } from './origin'
3
+ import { SIM_BRIDGE_CHANNEL, SIM_BRIDGE_VERSION, type SimMessageEventLike } from './messages'
4
+
5
+ /**
6
+ * The workspace scene channel of the Agent Sim bridge (M26, #24, #25, #489, #27). The host feeds
7
+ * the engine the workspace scene that Virtual view already draws: areas, agents, lead turn and
8
+ * child-job markers, and lead routes, as a full snapshot and then as diffs against the last
9
+ * revision the engine acknowledged. The engine renders it and never writes it back: there is no
10
+ * scene message from the frame other than an acknowledgement, a refusal and a measurement.
11
+ *
12
+ * Spec: docs/specs/sim-bridge-scene.md. Rules the code holds:
13
+ * - Scene messages share the bridge envelope (channel, version, per-mount bridgeId) and its
14
+ * origin/source checks, with their own size budget (`MAX_SCENE_MESSAGE_BYTES`).
15
+ * - One revision is in flight at a time. While it is unacknowledged, newer scene states coalesce:
16
+ * the next diff is computed from the acknowledged state to the newest one.
17
+ * - A diff names the revision it applies to. The engine refuses a diff whose base is not its
18
+ * current revision (`revision_gap`), and the host answers with a full snapshot.
19
+ * - An ack that names any revision other than the one in flight is ignored.
20
+ * - No ack within `ackTimeoutMs`, an `invalid` or `too_large` refusal, or a gap, resends a full
21
+ * snapshot, backing off; `busy` waits `retryAfterMs` (bounded) and resends; `version_unsupported`
22
+ * stops the feed.
23
+ */
24
+ export const SIM_SCENE_VERSION = 1
25
+ /** A scene message, JSON-encoded, may not exceed this. Snapshots are split into parts. */
26
+ export const MAX_SCENE_MESSAGE_BYTES = 64 * 1024
27
+ export const MAX_SCENE_ENTITIES_PER_MESSAGE = 200
28
+ /** The largest scene the host transmits or records as acknowledged. */
29
+ export const MAX_SCENE_ENTITIES = 5_000
30
+
31
+ const encoder = new TextEncoder()
32
+ const encodedBytes = (value: object) => encoder.encode(JSON.stringify(value)).byteLength
33
+
34
+ export const SIM_SCENE_KINDS = ['workspace', 'area', 'agent', 'turn', 'child_job', 'route'] as const
35
+ export type SimSceneKind = (typeof SIM_SCENE_KINDS)[number]
36
+ export const SIM_SCENE_STATUSES = [
37
+ 'error',
38
+ 'awaiting_input',
39
+ 'working',
40
+ 'queued',
41
+ 'offline',
42
+ 'unavailable',
43
+ 'idle',
44
+ 'completed',
45
+ 'cancelled',
46
+ 'archived',
47
+ ] as const
48
+ export type SimSceneStatus = (typeof SIM_SCENE_STATUSES)[number]
49
+ export const SIM_SCENE_ANIMATIONS = ['rest', 'work', 'wait', 'alert', 'celebrate', 'sleep'] as const
50
+ export type SimSceneAnimation = (typeof SIM_SCENE_ANIMATIONS)[number]
51
+ export const SIM_SCENE_TONES = [
52
+ 'neutral',
53
+ 'active',
54
+ 'attention',
55
+ 'danger',
56
+ 'success',
57
+ 'muted',
58
+ ] as const
59
+ export type SimSceneTone = (typeof SIM_SCENE_TONES)[number]
60
+
61
+ /**
62
+ * One scene entity on the wire. Ids are the scene graph's stable ids (`agent:<id>`,
63
+ * `conversation:<id>`, `turn:<intentId>`, ...; a route is `route:<turn id>`), so the same backend
64
+ * object is the same engine object across reloads. Coordinates are map units: the engine places
65
+ * its world from them and may scale, but keeps relative placement.
66
+ */
67
+ export type SimSceneEntity = Readonly<{
68
+ id: string
69
+ kind: SimSceneKind
70
+ /** The containing entity; absent only for the workspace. */
71
+ parentId?: string
72
+ label: string
73
+ status: SimSceneStatus
74
+ animation: SimSceneAnimation
75
+ tone: SimSceneTone
76
+ /** Areas and the workspace commons. */
77
+ rect?: Readonly<{ x: number; y: number; width: number; height: number }>
78
+ /** Agents, turns and child jobs. */
79
+ at?: Readonly<{ x: number; y: number }>
80
+ /** Agents. */
81
+ role?: 'lead' | 'agent'
82
+ characterRef?: string
83
+ /** No character reference: the engine shows its explicit default character. */
84
+ fallback?: boolean
85
+ /** Routes: from an agent to a turn marker. */
86
+ from?: string
87
+ to?: string
88
+ lead?: boolean
89
+ }>
90
+
91
+ export type SimHostSceneMessage =
92
+ | Readonly<{
93
+ type: 'host.scene.snapshot'
94
+ payload: Readonly<{
95
+ sceneVersion: typeof SIM_SCENE_VERSION
96
+ workspaceId: string
97
+ revision: number
98
+ /** 1-based part of `parts`. The engine applies the snapshot once every part arrived. */
99
+ part: number
100
+ parts: number
101
+ entities: readonly SimSceneEntity[]
102
+ }>
103
+ }>
104
+ | Readonly<{
105
+ type: 'host.scene.diff'
106
+ payload: Readonly<{
107
+ sceneVersion: typeof SIM_SCENE_VERSION
108
+ workspaceId: string
109
+ baseRevision: number
110
+ revision: number
111
+ upserts: readonly SimSceneEntity[]
112
+ removes: readonly string[]
113
+ }>
114
+ }>
115
+
116
+ export const SIM_SCENE_REFUSALS = [
117
+ 'revision_gap',
118
+ 'invalid',
119
+ 'too_large',
120
+ 'version_unsupported',
121
+ 'busy',
122
+ ] as const
123
+ export type SimSceneRefusal = (typeof SIM_SCENE_REFUSALS)[number]
124
+
125
+ /** What the engine measured for the scene, for #27's budgets. Durations in ms, memory in bytes. */
126
+ export type SimScenePerf = Readonly<{
127
+ revision: number
128
+ sampleWindowMs: number
129
+ frames: number
130
+ frameP50Ms: number
131
+ frameP95Ms: number
132
+ frameMaxMs: number
133
+ /** Frames over 50 ms in the window. */
134
+ longFrames: number
135
+ /** From the first snapshot part to the first frame drawn with it. */
136
+ loadMs: number
137
+ /** How long the last applied revision took to apply. */
138
+ applyMs: number
139
+ entities: number
140
+ /** `performance.memory.usedJSHeapSize` where the browser exposes it. */
141
+ heapBytes?: number
142
+ /** The renderer's own estimate of GPU memory (geometries + textures), when it keeps one. */
143
+ gpuBytes?: number
144
+ }>
145
+
146
+ export type SimFrameSceneMessage =
147
+ | Readonly<{ type: 'frame.scene.ack'; payload: Readonly<{ revision: number }> }>
148
+ | Readonly<{
149
+ type: 'frame.scene.nack'
150
+ payload: Readonly<{ revision: number; code: SimSceneRefusal; retryAfterMs?: number }>
151
+ }>
152
+ | Readonly<{ type: 'frame.scene.perf'; payload: SimScenePerf }>
153
+
154
+ const REVISION_MAX = Number.MAX_SAFE_INTEGER
155
+ const isRevision = (value: unknown): value is number =>
156
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= REVISION_MAX
157
+ const isMeasure = (value: unknown): value is number =>
158
+ typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1e13
159
+
160
+ const PERF_REQUIRED = [
161
+ 'revision',
162
+ 'sampleWindowMs',
163
+ 'frames',
164
+ 'frameP50Ms',
165
+ 'frameP95Ms',
166
+ 'frameMaxMs',
167
+ 'longFrames',
168
+ 'loadMs',
169
+ 'applyMs',
170
+ 'entities',
171
+ ] as const
172
+
173
+ /** Reads the payload of a `frame.scene.*` message; null when it is not exactly that shape. */
174
+ export function parseFrameScenePayload(
175
+ type: string,
176
+ payload: unknown
177
+ ): SimFrameSceneMessage | null {
178
+ switch (type) {
179
+ case 'frame.scene.ack': {
180
+ const body = strictRecord(payload, ['revision'])
181
+ return body && isRevision(body.revision)
182
+ ? { type, payload: { revision: body.revision } }
183
+ : null
184
+ }
185
+ case 'frame.scene.nack': {
186
+ const body = strictRecord(payload, ['revision', 'code'], ['retryAfterMs'])
187
+ const code = SIM_SCENE_REFUSALS.find((item) => item === body?.code)
188
+ if (!body || !code || !isRevision(body.revision)) return null
189
+ if ('retryAfterMs' in body && (code !== 'busy' || !isMeasure(body.retryAfterMs))) return null
190
+ return {
191
+ type,
192
+ payload: {
193
+ revision: body.revision,
194
+ code,
195
+ ...(typeof body.retryAfterMs === 'number' ? { retryAfterMs: body.retryAfterMs } : {}),
196
+ },
197
+ }
198
+ }
199
+ case 'frame.scene.perf': {
200
+ const body = strictRecord(payload, PERF_REQUIRED, ['heapBytes', 'gpuBytes'])
201
+ if (!body || !isRevision(body.revision)) return null
202
+ for (const key of [...PERF_REQUIRED, 'heapBytes', 'gpuBytes'])
203
+ if (key in body && !isMeasure(body[key])) return null
204
+ return { type, payload: body as SimScenePerf }
205
+ }
206
+ default:
207
+ return null
208
+ }
209
+ }
210
+
211
+ function sceneEnvelope(bridgeId: string, message: SimHostSceneMessage) {
212
+ return {
213
+ channel: SIM_BRIDGE_CHANNEL,
214
+ version: SIM_BRIDGE_VERSION,
215
+ bridgeId,
216
+ type: message.type,
217
+ payload: message.payload,
218
+ } as const
219
+ }
220
+
221
+ type PostTarget = Readonly<{ postMessage(message: unknown, targetOrigin: string): void }>
222
+
223
+ /** Host to frame, for scene messages. Same origin rule as `postToFrame`, and a size bound. */
224
+ export function postSceneToFrame(
225
+ frameWindow: PostTarget,
226
+ frameOrigin: string,
227
+ origins: OriginAllowlist,
228
+ bridgeId: string,
229
+ message: SimHostSceneMessage
230
+ ): void {
231
+ if (!origins.has(frameOrigin)) throw new Error('Refusing to post to an unlisted Agent Sim origin')
232
+ const envelope = sceneEnvelope(bridgeId, message)
233
+ if (encodedBytes(envelope) > MAX_SCENE_MESSAGE_BYTES)
234
+ throw new Error('Agent Sim scene message is over its size budget')
235
+ frameWindow.postMessage(envelope, frameOrigin)
236
+ }
237
+
238
+ const ID = /^[a-z_]+:[A-Za-z0-9_.:-]{1,160}$/
239
+ const LABEL_MAX = 200
240
+ const REF = /^[\x21-\x7e]{1,512}$/
241
+ const COORD_MAX = 1e6
242
+
243
+ const isCoord = (value: unknown): value is number =>
244
+ typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= COORD_MAX
245
+
246
+ function readEntity(value: unknown): SimSceneEntity | null {
247
+ const body = strictRecord(
248
+ value,
249
+ ['id', 'kind', 'label', 'status', 'animation', 'tone'],
250
+ ['parentId', 'rect', 'at', 'role', 'characterRef', 'fallback', 'from', 'to', 'lead']
251
+ )
252
+ if (!body) return null
253
+ const kind = SIM_SCENE_KINDS.find((item) => item === body.kind)
254
+ const status = SIM_SCENE_STATUSES.find((item) => item === body.status)
255
+ const animation = SIM_SCENE_ANIMATIONS.find((item) => item === body.animation)
256
+ const tone = SIM_SCENE_TONES.find((item) => item === body.tone)
257
+ const id = boundedString(body.id, ID)
258
+ if (!kind || !status || !animation || !tone || !id) return null
259
+ if (typeof body.label !== 'string' || body.label.length > LABEL_MAX) return null
260
+ if ('parentId' in body && !boundedString(body.parentId, ID)) return null
261
+ if ('rect' in body) {
262
+ const rect = strictRecord(body.rect, ['x', 'y', 'width', 'height'])
263
+ if (!rect || ![rect.x, rect.y, rect.width, rect.height].every(isCoord)) return null
264
+ }
265
+ if ('at' in body) {
266
+ const at = strictRecord(body.at, ['x', 'y'])
267
+ if (!at || !isCoord(at.x) || !isCoord(at.y)) return null
268
+ }
269
+ if ('role' in body && body.role !== 'lead' && body.role !== 'agent') return null
270
+ if ('characterRef' in body && !boundedString(body.characterRef, REF)) return null
271
+ if ('fallback' in body && typeof body.fallback !== 'boolean') return null
272
+ if ('lead' in body && typeof body.lead !== 'boolean') return null
273
+ for (const key of ['from', 'to']) if (key in body && !boundedString(body[key], ID)) return null
274
+ if (kind === 'route' && (!('from' in body) || !('to' in body))) return null
275
+ return body as SimSceneEntity
276
+ }
277
+
278
+ function readEntities(value: unknown): SimSceneEntity[] | null {
279
+ if (!Array.isArray(value) || value.length > MAX_SCENE_ENTITIES_PER_MESSAGE) return null
280
+ const entities: SimSceneEntity[] = []
281
+ for (let index = 0; index < value.length; index++) {
282
+ const descriptor = Object.getOwnPropertyDescriptor(value, index)
283
+ if (!descriptor || !('value' in descriptor)) return null
284
+ const entity = readEntity(descriptor.value)
285
+ if (!entity) return null
286
+ entities.push(entity)
287
+ }
288
+ return entities
289
+ }
290
+
291
+ export type SimSceneParseFailure =
292
+ | 'origin_refused'
293
+ | 'source_mismatch'
294
+ | 'not_a_scene_message'
295
+ | 'bridge_mismatch'
296
+ | 'version_unsupported'
297
+ | 'too_large'
298
+ | 'invalid'
299
+
300
+ /**
301
+ * Engine side: accepts a host scene message only from the embedding window at its exact origin,
302
+ * on this mount's bridge id, in a supported scene version, within budget and with every entity in
303
+ * shape. The engine answers `invalid`, `too_large` or `version_unsupported` with a `nack`.
304
+ */
305
+ export function parseHostSceneMessage(
306
+ event: SimMessageEventLike,
307
+ context: Readonly<{ parentOrigin: string; parentWindow: unknown; bridgeId: string }>
308
+ ):
309
+ | Readonly<{ ok: true; message: SimHostSceneMessage }>
310
+ | Readonly<{ ok: false; reason: SimSceneParseFailure }> {
311
+ const fail = (reason: SimSceneParseFailure) => ({ ok: false, reason }) as const
312
+ if (event.origin !== context.parentOrigin) return fail('origin_refused')
313
+ if (event.source == null || event.source !== context.parentWindow) return fail('source_mismatch')
314
+ const data = strictRecord(event.data, ['channel', 'version', 'bridgeId', 'type', 'payload'])
315
+ if (!data || data.channel !== SIM_BRIDGE_CHANNEL) return fail('not_a_scene_message')
316
+ if (data.type !== 'host.scene.snapshot' && data.type !== 'host.scene.diff')
317
+ return fail('not_a_scene_message')
318
+ if (data.version !== SIM_BRIDGE_VERSION) return fail('version_unsupported')
319
+ if (data.bridgeId !== context.bridgeId) return fail('bridge_mismatch')
320
+ if (!isPlainRecord(data.payload)) return fail('invalid')
321
+ let size: number
322
+ try {
323
+ size = encodedBytes(data)
324
+ } catch {
325
+ return fail('invalid')
326
+ }
327
+ if (size > MAX_SCENE_MESSAGE_BYTES) return fail('too_large')
328
+ if (data.payload.sceneVersion !== SIM_SCENE_VERSION) return fail('version_unsupported')
329
+ if (data.type === 'host.scene.snapshot') {
330
+ const body = strictRecord(data.payload, [
331
+ 'sceneVersion',
332
+ 'workspaceId',
333
+ 'revision',
334
+ 'part',
335
+ 'parts',
336
+ 'entities',
337
+ ])
338
+ const entities = readEntities(body?.entities)
339
+ if (
340
+ !body ||
341
+ !entities ||
342
+ typeof body.workspaceId !== 'string' ||
343
+ !isRevision(body.revision) ||
344
+ !Number.isSafeInteger(body.parts) ||
345
+ !Number.isSafeInteger(body.part) ||
346
+ (body.part as number) < 1 ||
347
+ (body.part as number) > (body.parts as number) ||
348
+ (body.parts as number) > MAX_SCENE_ENTITIES
349
+ )
350
+ return fail('invalid')
351
+ return {
352
+ ok: true,
353
+ message: {
354
+ type: 'host.scene.snapshot',
355
+ payload: {
356
+ sceneVersion: SIM_SCENE_VERSION,
357
+ workspaceId: body.workspaceId,
358
+ revision: body.revision,
359
+ part: body.part as number,
360
+ parts: body.parts as number,
361
+ entities,
362
+ },
363
+ },
364
+ }
365
+ }
366
+ const body = strictRecord(data.payload, [
367
+ 'sceneVersion',
368
+ 'workspaceId',
369
+ 'baseRevision',
370
+ 'revision',
371
+ 'upserts',
372
+ 'removes',
373
+ ])
374
+ const upserts = readEntities(body?.upserts)
375
+ const removes = body?.removes
376
+ if (
377
+ !body ||
378
+ !upserts ||
379
+ typeof body.workspaceId !== 'string' ||
380
+ !isRevision(body.baseRevision) ||
381
+ !isRevision(body.revision) ||
382
+ body.revision <= body.baseRevision ||
383
+ !Array.isArray(removes) ||
384
+ removes.length > MAX_SCENE_ENTITIES_PER_MESSAGE ||
385
+ !removes.every((id) => boundedString(id, ID))
386
+ )
387
+ return fail('invalid')
388
+ return {
389
+ ok: true,
390
+ message: {
391
+ type: 'host.scene.diff',
392
+ payload: {
393
+ sceneVersion: SIM_SCENE_VERSION,
394
+ workspaceId: body.workspaceId,
395
+ baseRevision: body.baseRevision,
396
+ revision: body.revision,
397
+ upserts,
398
+ removes: removes as string[],
399
+ },
400
+ },
401
+ }
402
+ }
403
+
404
+ export type SimSceneFeedState =
405
+ | 'idle'
406
+ | 'sending'
407
+ | 'synced'
408
+ | 'backing_off'
409
+ | 'stopped'
410
+ | 'disposed'
411
+
412
+ export type SimSceneFeedStats = Readonly<{
413
+ state: SimSceneFeedState
414
+ /** The revision the engine last acknowledged; 0 before the first. */
415
+ ackedRevision: number
416
+ snapshots: number
417
+ diffs: number
418
+ timeouts: number
419
+ refusals: number
420
+ /** Acks naming a revision that was not in flight. */
421
+ ignoredAcks: number
422
+ lastPerf: SimScenePerf | null
423
+ }>
424
+
425
+ export type SimSceneFeedOptions = Readonly<{
426
+ workspaceId: string
427
+ send(message: SimHostSceneMessage): void
428
+ onPerf?(perf: SimScenePerf): void
429
+ ackTimeoutMs?: number
430
+ /** Schedules `run` after `ms` and returns a canceller. */
431
+ schedule?: (run: () => void, ms: number) => () => void
432
+ }>
433
+
434
+ export type SimSceneFeed = Readonly<{
435
+ /** The newest scene. It is sent now, or coalesced into the next send after the pending ack. */
436
+ update(entities: readonly SimSceneEntity[]): void
437
+ /** A validated `frame.scene.*` message from the current frame. */
438
+ receive(message: SimFrameSceneMessage): void
439
+ stats(): SimSceneFeedStats
440
+ dispose(): void
441
+ }>
442
+
443
+ const DEFAULT_ACK_TIMEOUT_MS = 5_000
444
+ const BACKOFF_MIN_MS = 500
445
+ const BACKOFF_MAX_MS = 30_000
446
+
447
+ function defaultSchedule(run: () => void, ms: number): () => void {
448
+ const id = setTimeout(run, ms)
449
+ return () => clearTimeout(id)
450
+ }
451
+
452
+ function sameEntity(a: SimSceneEntity, b: SimSceneEntity) {
453
+ return JSON.stringify(a) === JSON.stringify(b)
454
+ }
455
+
456
+ /**
457
+ * The host side of the scene channel: keeps what the engine acknowledged, sends one revision at a
458
+ * time, and recovers from refusals, gaps and silence by resending a full snapshot with backoff.
459
+ */
460
+ export function createSimSceneFeed(options: SimSceneFeedOptions): SimSceneFeed {
461
+ const schedule = options.schedule ?? defaultSchedule
462
+ const ackTimeoutMs = Math.min(
463
+ Math.max(options.ackTimeoutMs ?? DEFAULT_ACK_TIMEOUT_MS, 250),
464
+ 60_000
465
+ )
466
+ let desired: ReadonlyMap<string, SimSceneEntity> | null = null
467
+ let acked = new Map<string, SimSceneEntity>()
468
+ let ackedRevision = 0
469
+ let nextRevision = 1
470
+ let needSnapshot = true
471
+ let inFlight: { revision: number; target: ReadonlyMap<string, SimSceneEntity> } | null = null
472
+ let cancelTimer: (() => void) | undefined
473
+ let state: SimSceneFeedState = 'idle'
474
+ let failures = 0
475
+ const counts = { snapshots: 0, diffs: 0, timeouts: 0, refusals: 0, ignoredAcks: 0 }
476
+ let lastPerf: SimScenePerf | null = null
477
+
478
+ const clearTimer = () => {
479
+ cancelTimer?.()
480
+ cancelTimer = undefined
481
+ }
482
+ const backOff = (ms: number) => {
483
+ clearTimer()
484
+ state = 'backing_off'
485
+ cancelTimer = schedule(() => {
486
+ cancelTimer = undefined
487
+ state = 'idle'
488
+ pump()
489
+ }, ms)
490
+ }
491
+ const retryLater = () => {
492
+ const delay = Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * 2 ** failures)
493
+ failures += 1
494
+ backOff(delay)
495
+ }
496
+
497
+ function pump() {
498
+ if (state === 'disposed' || state === 'stopped' || state === 'backing_off') return
499
+ if (inFlight || !desired) return
500
+ const target = desired
501
+ const messages: SimHostSceneMessage[] = []
502
+ const revision = nextRevision
503
+ if (needSnapshot) {
504
+ const entities = [...target.values()]
505
+ const chunks: SimSceneEntity[][] = [[]]
506
+ // Reserve the maximum bridge id (64 characters) and part-number widths.
507
+ // Count each entity once, rather than serializing every growing candidate chunk.
508
+ const overhead = encodedBytes(
509
+ sceneEnvelope('x'.repeat(64), {
510
+ type: 'host.scene.snapshot',
511
+ payload: {
512
+ sceneVersion: SIM_SCENE_VERSION,
513
+ workspaceId: options.workspaceId,
514
+ revision,
515
+ part: MAX_SCENE_ENTITIES,
516
+ parts: MAX_SCENE_ENTITIES,
517
+ entities: [],
518
+ },
519
+ })
520
+ )
521
+ let chunk = chunks[0]!
522
+ let bytes = overhead
523
+ for (const entity of entities) {
524
+ const entityBytes = encodedBytes(entity)
525
+ if (overhead + entityBytes > MAX_SCENE_MESSAGE_BYTES)
526
+ throw new Error('Agent Sim scene entity is over its size budget')
527
+ if (
528
+ chunk.length > 0 &&
529
+ (chunk.length === MAX_SCENE_ENTITIES_PER_MESSAGE ||
530
+ bytes + 1 + entityBytes > MAX_SCENE_MESSAGE_BYTES)
531
+ ) {
532
+ chunk = []
533
+ chunks.push(chunk)
534
+ bytes = overhead
535
+ }
536
+ bytes += entityBytes + (chunk.length > 0 ? 1 : 0)
537
+ chunk.push(entity)
538
+ }
539
+ for (const [index, partEntities] of chunks.entries())
540
+ messages.push({
541
+ type: 'host.scene.snapshot',
542
+ payload: {
543
+ sceneVersion: SIM_SCENE_VERSION,
544
+ workspaceId: options.workspaceId,
545
+ revision,
546
+ part: index + 1,
547
+ parts: chunks.length,
548
+ entities: partEntities,
549
+ },
550
+ })
551
+ } else {
552
+ const upserts: SimSceneEntity[] = []
553
+ const removes: string[] = []
554
+ for (const [id, entity] of target) {
555
+ const old = acked.get(id)
556
+ if (!old || !sameEntity(old, entity)) upserts.push(entity)
557
+ }
558
+ for (const id of acked.keys()) if (!target.has(id)) removes.push(id)
559
+ if (upserts.length === 0 && removes.length === 0) {
560
+ state = 'synced'
561
+ return
562
+ }
563
+ // A change too big for one message is cheaper as a snapshot than as a split diff.
564
+ if (
565
+ upserts.length > MAX_SCENE_ENTITIES_PER_MESSAGE ||
566
+ removes.length > MAX_SCENE_ENTITIES_PER_MESSAGE
567
+ ) {
568
+ needSnapshot = true
569
+ pump()
570
+ return
571
+ }
572
+ const message: SimHostSceneMessage = {
573
+ type: 'host.scene.diff',
574
+ payload: {
575
+ sceneVersion: SIM_SCENE_VERSION,
576
+ workspaceId: options.workspaceId,
577
+ baseRevision: ackedRevision,
578
+ revision,
579
+ upserts,
580
+ removes,
581
+ },
582
+ }
583
+ if (encodedBytes(sceneEnvelope('x'.repeat(64), message)) > MAX_SCENE_MESSAGE_BYTES) {
584
+ needSnapshot = true
585
+ pump()
586
+ return
587
+ }
588
+ messages.push(message)
589
+ }
590
+ nextRevision += 1
591
+ // In flight before the first post: an engine may answer synchronously.
592
+ inFlight = { revision, target }
593
+ state = 'sending'
594
+ clearTimer()
595
+ cancelTimer = schedule(() => {
596
+ cancelTimer = undefined
597
+ if (!inFlight || inFlight.revision !== revision) return
598
+ counts.timeouts += 1
599
+ inFlight = null
600
+ needSnapshot = true
601
+ retryLater()
602
+ }, ackTimeoutMs)
603
+ if (messages[0]!.type === 'host.scene.snapshot') counts.snapshots += 1
604
+ else counts.diffs += 1
605
+ try {
606
+ for (const message of messages) {
607
+ // An answer that arrived mid-snapshot ended this revision; stop sending its parts.
608
+ if (inFlight?.revision !== revision && messages.length > 1) break
609
+ options.send(message)
610
+ }
611
+ } catch {
612
+ if (inFlight?.revision === revision) {
613
+ clearTimer()
614
+ inFlight = null
615
+ needSnapshot = true
616
+ retryLater()
617
+ }
618
+ }
619
+ }
620
+
621
+ return {
622
+ update(entities) {
623
+ if (state === 'disposed') return
624
+ const next = new Map(entities.map((entity) => [entity.id, entity]))
625
+ // Acknowledgements must describe exactly the bounded scene sent to the engine.
626
+ desired =
627
+ next.size <= MAX_SCENE_ENTITIES ? next : new Map([...next].slice(0, MAX_SCENE_ENTITIES))
628
+ pump()
629
+ },
630
+ receive(message) {
631
+ if (state === 'disposed') return
632
+ if (message.type === 'frame.scene.perf') {
633
+ lastPerf = message.payload
634
+ options.onPerf?.(message.payload)
635
+ return
636
+ }
637
+ if (!inFlight || message.payload.revision !== inFlight.revision) {
638
+ counts.ignoredAcks += 1
639
+ return
640
+ }
641
+ clearTimer()
642
+ const { revision, target } = inFlight
643
+ inFlight = null
644
+ if (message.type === 'frame.scene.ack') {
645
+ acked = new Map(target)
646
+ ackedRevision = revision
647
+ needSnapshot = false
648
+ failures = 0
649
+ state = 'idle'
650
+ pump()
651
+ if (!inFlight && (state as SimSceneFeedState) === 'idle') state = 'synced'
652
+ return
653
+ }
654
+ counts.refusals += 1
655
+ switch (message.payload.code) {
656
+ case 'version_unsupported':
657
+ state = 'stopped'
658
+ return
659
+ case 'busy':
660
+ backOff(Math.min(Math.max(message.payload.retryAfterMs ?? BACKOFF_MIN_MS, 100), 10_000))
661
+ return
662
+ default:
663
+ needSnapshot = true
664
+ retryLater()
665
+ }
666
+ },
667
+ stats: () => ({
668
+ state,
669
+ ackedRevision,
670
+ ...counts,
671
+ lastPerf,
672
+ }),
673
+ dispose() {
674
+ clearTimer()
675
+ inFlight = null
676
+ state = 'disposed'
677
+ },
678
+ }
679
+ }