@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.
- package/README.md +6 -5
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/sim-bridge/index.d.ts +1 -0
- package/dist/sim-bridge/index.js +1 -0
- package/dist/sim-bridge/messages.d.ts +3 -1
- package/dist/sim-bridge/messages.js +7 -0
- package/dist/sim-bridge/scene.d.ts +191 -0
- package/dist/sim-bridge/scene.js +528 -0
- package/package.json +1 -1
- package/src/index.ts +0 -9
- package/src/sim-bridge/index.ts +1 -0
- package/src/sim-bridge/messages.ts +9 -1
- package/src/sim-bridge/scene.ts +679 -0
- package/dist/engine.d.ts +0 -69
- package/dist/engine.js +0 -105
- package/src/engine.ts +0 -133
|
@@ -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
|
+
}
|