@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,528 @@
|
|
|
1
|
+
import { boundedString, isPlainRecord, strictRecord } from './record';
|
|
2
|
+
import { SIM_BRIDGE_CHANNEL, SIM_BRIDGE_VERSION } from './messages';
|
|
3
|
+
/**
|
|
4
|
+
* The workspace scene channel of the Agent Sim bridge (M26, #24, #25, #489, #27). The host feeds
|
|
5
|
+
* the engine the workspace scene that Virtual view already draws: areas, agents, lead turn and
|
|
6
|
+
* child-job markers, and lead routes, as a full snapshot and then as diffs against the last
|
|
7
|
+
* revision the engine acknowledged. The engine renders it and never writes it back: there is no
|
|
8
|
+
* scene message from the frame other than an acknowledgement, a refusal and a measurement.
|
|
9
|
+
*
|
|
10
|
+
* Spec: docs/specs/sim-bridge-scene.md. Rules the code holds:
|
|
11
|
+
* - Scene messages share the bridge envelope (channel, version, per-mount bridgeId) and its
|
|
12
|
+
* origin/source checks, with their own size budget (`MAX_SCENE_MESSAGE_BYTES`).
|
|
13
|
+
* - One revision is in flight at a time. While it is unacknowledged, newer scene states coalesce:
|
|
14
|
+
* the next diff is computed from the acknowledged state to the newest one.
|
|
15
|
+
* - A diff names the revision it applies to. The engine refuses a diff whose base is not its
|
|
16
|
+
* current revision (`revision_gap`), and the host answers with a full snapshot.
|
|
17
|
+
* - An ack that names any revision other than the one in flight is ignored.
|
|
18
|
+
* - No ack within `ackTimeoutMs`, an `invalid` or `too_large` refusal, or a gap, resends a full
|
|
19
|
+
* snapshot, backing off; `busy` waits `retryAfterMs` (bounded) and resends; `version_unsupported`
|
|
20
|
+
* stops the feed.
|
|
21
|
+
*/
|
|
22
|
+
export const SIM_SCENE_VERSION = 1;
|
|
23
|
+
/** A scene message, JSON-encoded, may not exceed this. Snapshots are split into parts. */
|
|
24
|
+
export const MAX_SCENE_MESSAGE_BYTES = 64 * 1024;
|
|
25
|
+
export const MAX_SCENE_ENTITIES_PER_MESSAGE = 200;
|
|
26
|
+
/** The largest scene the host transmits or records as acknowledged. */
|
|
27
|
+
export const MAX_SCENE_ENTITIES = 5_000;
|
|
28
|
+
const encoder = new TextEncoder();
|
|
29
|
+
const encodedBytes = (value) => encoder.encode(JSON.stringify(value)).byteLength;
|
|
30
|
+
export const SIM_SCENE_KINDS = ['workspace', 'area', 'agent', 'turn', 'child_job', 'route'];
|
|
31
|
+
export const SIM_SCENE_STATUSES = [
|
|
32
|
+
'error',
|
|
33
|
+
'awaiting_input',
|
|
34
|
+
'working',
|
|
35
|
+
'queued',
|
|
36
|
+
'offline',
|
|
37
|
+
'unavailable',
|
|
38
|
+
'idle',
|
|
39
|
+
'completed',
|
|
40
|
+
'cancelled',
|
|
41
|
+
'archived',
|
|
42
|
+
];
|
|
43
|
+
export const SIM_SCENE_ANIMATIONS = ['rest', 'work', 'wait', 'alert', 'celebrate', 'sleep'];
|
|
44
|
+
export const SIM_SCENE_TONES = [
|
|
45
|
+
'neutral',
|
|
46
|
+
'active',
|
|
47
|
+
'attention',
|
|
48
|
+
'danger',
|
|
49
|
+
'success',
|
|
50
|
+
'muted',
|
|
51
|
+
];
|
|
52
|
+
export const SIM_SCENE_REFUSALS = [
|
|
53
|
+
'revision_gap',
|
|
54
|
+
'invalid',
|
|
55
|
+
'too_large',
|
|
56
|
+
'version_unsupported',
|
|
57
|
+
'busy',
|
|
58
|
+
];
|
|
59
|
+
const REVISION_MAX = Number.MAX_SAFE_INTEGER;
|
|
60
|
+
const isRevision = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= REVISION_MAX;
|
|
61
|
+
const isMeasure = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1e13;
|
|
62
|
+
const PERF_REQUIRED = [
|
|
63
|
+
'revision',
|
|
64
|
+
'sampleWindowMs',
|
|
65
|
+
'frames',
|
|
66
|
+
'frameP50Ms',
|
|
67
|
+
'frameP95Ms',
|
|
68
|
+
'frameMaxMs',
|
|
69
|
+
'longFrames',
|
|
70
|
+
'loadMs',
|
|
71
|
+
'applyMs',
|
|
72
|
+
'entities',
|
|
73
|
+
];
|
|
74
|
+
/** Reads the payload of a `frame.scene.*` message; null when it is not exactly that shape. */
|
|
75
|
+
export function parseFrameScenePayload(type, payload) {
|
|
76
|
+
switch (type) {
|
|
77
|
+
case 'frame.scene.ack': {
|
|
78
|
+
const body = strictRecord(payload, ['revision']);
|
|
79
|
+
return body && isRevision(body.revision)
|
|
80
|
+
? { type, payload: { revision: body.revision } }
|
|
81
|
+
: null;
|
|
82
|
+
}
|
|
83
|
+
case 'frame.scene.nack': {
|
|
84
|
+
const body = strictRecord(payload, ['revision', 'code'], ['retryAfterMs']);
|
|
85
|
+
const code = SIM_SCENE_REFUSALS.find((item) => item === body?.code);
|
|
86
|
+
if (!body || !code || !isRevision(body.revision))
|
|
87
|
+
return null;
|
|
88
|
+
if ('retryAfterMs' in body && (code !== 'busy' || !isMeasure(body.retryAfterMs)))
|
|
89
|
+
return null;
|
|
90
|
+
return {
|
|
91
|
+
type,
|
|
92
|
+
payload: {
|
|
93
|
+
revision: body.revision,
|
|
94
|
+
code,
|
|
95
|
+
...(typeof body.retryAfterMs === 'number' ? { retryAfterMs: body.retryAfterMs } : {}),
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
case 'frame.scene.perf': {
|
|
100
|
+
const body = strictRecord(payload, PERF_REQUIRED, ['heapBytes', 'gpuBytes']);
|
|
101
|
+
if (!body || !isRevision(body.revision))
|
|
102
|
+
return null;
|
|
103
|
+
for (const key of [...PERF_REQUIRED, 'heapBytes', 'gpuBytes'])
|
|
104
|
+
if (key in body && !isMeasure(body[key]))
|
|
105
|
+
return null;
|
|
106
|
+
return { type, payload: body };
|
|
107
|
+
}
|
|
108
|
+
default:
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function sceneEnvelope(bridgeId, message) {
|
|
113
|
+
return {
|
|
114
|
+
channel: SIM_BRIDGE_CHANNEL,
|
|
115
|
+
version: SIM_BRIDGE_VERSION,
|
|
116
|
+
bridgeId,
|
|
117
|
+
type: message.type,
|
|
118
|
+
payload: message.payload,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Host to frame, for scene messages. Same origin rule as `postToFrame`, and a size bound. */
|
|
122
|
+
export function postSceneToFrame(frameWindow, frameOrigin, origins, bridgeId, message) {
|
|
123
|
+
if (!origins.has(frameOrigin))
|
|
124
|
+
throw new Error('Refusing to post to an unlisted Agent Sim origin');
|
|
125
|
+
const envelope = sceneEnvelope(bridgeId, message);
|
|
126
|
+
if (encodedBytes(envelope) > MAX_SCENE_MESSAGE_BYTES)
|
|
127
|
+
throw new Error('Agent Sim scene message is over its size budget');
|
|
128
|
+
frameWindow.postMessage(envelope, frameOrigin);
|
|
129
|
+
}
|
|
130
|
+
const ID = /^[a-z_]+:[A-Za-z0-9_.:-]{1,160}$/;
|
|
131
|
+
const LABEL_MAX = 200;
|
|
132
|
+
const REF = /^[\x21-\x7e]{1,512}$/;
|
|
133
|
+
const COORD_MAX = 1e6;
|
|
134
|
+
const isCoord = (value) => typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= COORD_MAX;
|
|
135
|
+
function readEntity(value) {
|
|
136
|
+
const body = strictRecord(value, ['id', 'kind', 'label', 'status', 'animation', 'tone'], ['parentId', 'rect', 'at', 'role', 'characterRef', 'fallback', 'from', 'to', 'lead']);
|
|
137
|
+
if (!body)
|
|
138
|
+
return null;
|
|
139
|
+
const kind = SIM_SCENE_KINDS.find((item) => item === body.kind);
|
|
140
|
+
const status = SIM_SCENE_STATUSES.find((item) => item === body.status);
|
|
141
|
+
const animation = SIM_SCENE_ANIMATIONS.find((item) => item === body.animation);
|
|
142
|
+
const tone = SIM_SCENE_TONES.find((item) => item === body.tone);
|
|
143
|
+
const id = boundedString(body.id, ID);
|
|
144
|
+
if (!kind || !status || !animation || !tone || !id)
|
|
145
|
+
return null;
|
|
146
|
+
if (typeof body.label !== 'string' || body.label.length > LABEL_MAX)
|
|
147
|
+
return null;
|
|
148
|
+
if ('parentId' in body && !boundedString(body.parentId, ID))
|
|
149
|
+
return null;
|
|
150
|
+
if ('rect' in body) {
|
|
151
|
+
const rect = strictRecord(body.rect, ['x', 'y', 'width', 'height']);
|
|
152
|
+
if (!rect || ![rect.x, rect.y, rect.width, rect.height].every(isCoord))
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
if ('at' in body) {
|
|
156
|
+
const at = strictRecord(body.at, ['x', 'y']);
|
|
157
|
+
if (!at || !isCoord(at.x) || !isCoord(at.y))
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
if ('role' in body && body.role !== 'lead' && body.role !== 'agent')
|
|
161
|
+
return null;
|
|
162
|
+
if ('characterRef' in body && !boundedString(body.characterRef, REF))
|
|
163
|
+
return null;
|
|
164
|
+
if ('fallback' in body && typeof body.fallback !== 'boolean')
|
|
165
|
+
return null;
|
|
166
|
+
if ('lead' in body && typeof body.lead !== 'boolean')
|
|
167
|
+
return null;
|
|
168
|
+
for (const key of ['from', 'to'])
|
|
169
|
+
if (key in body && !boundedString(body[key], ID))
|
|
170
|
+
return null;
|
|
171
|
+
if (kind === 'route' && (!('from' in body) || !('to' in body)))
|
|
172
|
+
return null;
|
|
173
|
+
return body;
|
|
174
|
+
}
|
|
175
|
+
function readEntities(value) {
|
|
176
|
+
if (!Array.isArray(value) || value.length > MAX_SCENE_ENTITIES_PER_MESSAGE)
|
|
177
|
+
return null;
|
|
178
|
+
const entities = [];
|
|
179
|
+
for (let index = 0; index < value.length; index++) {
|
|
180
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, index);
|
|
181
|
+
if (!descriptor || !('value' in descriptor))
|
|
182
|
+
return null;
|
|
183
|
+
const entity = readEntity(descriptor.value);
|
|
184
|
+
if (!entity)
|
|
185
|
+
return null;
|
|
186
|
+
entities.push(entity);
|
|
187
|
+
}
|
|
188
|
+
return entities;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Engine side: accepts a host scene message only from the embedding window at its exact origin,
|
|
192
|
+
* on this mount's bridge id, in a supported scene version, within budget and with every entity in
|
|
193
|
+
* shape. The engine answers `invalid`, `too_large` or `version_unsupported` with a `nack`.
|
|
194
|
+
*/
|
|
195
|
+
export function parseHostSceneMessage(event, context) {
|
|
196
|
+
const fail = (reason) => ({ ok: false, reason });
|
|
197
|
+
if (event.origin !== context.parentOrigin)
|
|
198
|
+
return fail('origin_refused');
|
|
199
|
+
if (event.source == null || event.source !== context.parentWindow)
|
|
200
|
+
return fail('source_mismatch');
|
|
201
|
+
const data = strictRecord(event.data, ['channel', 'version', 'bridgeId', 'type', 'payload']);
|
|
202
|
+
if (!data || data.channel !== SIM_BRIDGE_CHANNEL)
|
|
203
|
+
return fail('not_a_scene_message');
|
|
204
|
+
if (data.type !== 'host.scene.snapshot' && data.type !== 'host.scene.diff')
|
|
205
|
+
return fail('not_a_scene_message');
|
|
206
|
+
if (data.version !== SIM_BRIDGE_VERSION)
|
|
207
|
+
return fail('version_unsupported');
|
|
208
|
+
if (data.bridgeId !== context.bridgeId)
|
|
209
|
+
return fail('bridge_mismatch');
|
|
210
|
+
if (!isPlainRecord(data.payload))
|
|
211
|
+
return fail('invalid');
|
|
212
|
+
let size;
|
|
213
|
+
try {
|
|
214
|
+
size = encodedBytes(data);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return fail('invalid');
|
|
218
|
+
}
|
|
219
|
+
if (size > MAX_SCENE_MESSAGE_BYTES)
|
|
220
|
+
return fail('too_large');
|
|
221
|
+
if (data.payload.sceneVersion !== SIM_SCENE_VERSION)
|
|
222
|
+
return fail('version_unsupported');
|
|
223
|
+
if (data.type === 'host.scene.snapshot') {
|
|
224
|
+
const body = strictRecord(data.payload, [
|
|
225
|
+
'sceneVersion',
|
|
226
|
+
'workspaceId',
|
|
227
|
+
'revision',
|
|
228
|
+
'part',
|
|
229
|
+
'parts',
|
|
230
|
+
'entities',
|
|
231
|
+
]);
|
|
232
|
+
const entities = readEntities(body?.entities);
|
|
233
|
+
if (!body ||
|
|
234
|
+
!entities ||
|
|
235
|
+
typeof body.workspaceId !== 'string' ||
|
|
236
|
+
!isRevision(body.revision) ||
|
|
237
|
+
!Number.isSafeInteger(body.parts) ||
|
|
238
|
+
!Number.isSafeInteger(body.part) ||
|
|
239
|
+
body.part < 1 ||
|
|
240
|
+
body.part > body.parts ||
|
|
241
|
+
body.parts > MAX_SCENE_ENTITIES)
|
|
242
|
+
return fail('invalid');
|
|
243
|
+
return {
|
|
244
|
+
ok: true,
|
|
245
|
+
message: {
|
|
246
|
+
type: 'host.scene.snapshot',
|
|
247
|
+
payload: {
|
|
248
|
+
sceneVersion: SIM_SCENE_VERSION,
|
|
249
|
+
workspaceId: body.workspaceId,
|
|
250
|
+
revision: body.revision,
|
|
251
|
+
part: body.part,
|
|
252
|
+
parts: body.parts,
|
|
253
|
+
entities,
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const body = strictRecord(data.payload, [
|
|
259
|
+
'sceneVersion',
|
|
260
|
+
'workspaceId',
|
|
261
|
+
'baseRevision',
|
|
262
|
+
'revision',
|
|
263
|
+
'upserts',
|
|
264
|
+
'removes',
|
|
265
|
+
]);
|
|
266
|
+
const upserts = readEntities(body?.upserts);
|
|
267
|
+
const removes = body?.removes;
|
|
268
|
+
if (!body ||
|
|
269
|
+
!upserts ||
|
|
270
|
+
typeof body.workspaceId !== 'string' ||
|
|
271
|
+
!isRevision(body.baseRevision) ||
|
|
272
|
+
!isRevision(body.revision) ||
|
|
273
|
+
body.revision <= body.baseRevision ||
|
|
274
|
+
!Array.isArray(removes) ||
|
|
275
|
+
removes.length > MAX_SCENE_ENTITIES_PER_MESSAGE ||
|
|
276
|
+
!removes.every((id) => boundedString(id, ID)))
|
|
277
|
+
return fail('invalid');
|
|
278
|
+
return {
|
|
279
|
+
ok: true,
|
|
280
|
+
message: {
|
|
281
|
+
type: 'host.scene.diff',
|
|
282
|
+
payload: {
|
|
283
|
+
sceneVersion: SIM_SCENE_VERSION,
|
|
284
|
+
workspaceId: body.workspaceId,
|
|
285
|
+
baseRevision: body.baseRevision,
|
|
286
|
+
revision: body.revision,
|
|
287
|
+
upserts,
|
|
288
|
+
removes: removes,
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const DEFAULT_ACK_TIMEOUT_MS = 5_000;
|
|
294
|
+
const BACKOFF_MIN_MS = 500;
|
|
295
|
+
const BACKOFF_MAX_MS = 30_000;
|
|
296
|
+
function defaultSchedule(run, ms) {
|
|
297
|
+
const id = setTimeout(run, ms);
|
|
298
|
+
return () => clearTimeout(id);
|
|
299
|
+
}
|
|
300
|
+
function sameEntity(a, b) {
|
|
301
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The host side of the scene channel: keeps what the engine acknowledged, sends one revision at a
|
|
305
|
+
* time, and recovers from refusals, gaps and silence by resending a full snapshot with backoff.
|
|
306
|
+
*/
|
|
307
|
+
export function createSimSceneFeed(options) {
|
|
308
|
+
const schedule = options.schedule ?? defaultSchedule;
|
|
309
|
+
const ackTimeoutMs = Math.min(Math.max(options.ackTimeoutMs ?? DEFAULT_ACK_TIMEOUT_MS, 250), 60_000);
|
|
310
|
+
let desired = null;
|
|
311
|
+
let acked = new Map();
|
|
312
|
+
let ackedRevision = 0;
|
|
313
|
+
let nextRevision = 1;
|
|
314
|
+
let needSnapshot = true;
|
|
315
|
+
let inFlight = null;
|
|
316
|
+
let cancelTimer;
|
|
317
|
+
let state = 'idle';
|
|
318
|
+
let failures = 0;
|
|
319
|
+
const counts = { snapshots: 0, diffs: 0, timeouts: 0, refusals: 0, ignoredAcks: 0 };
|
|
320
|
+
let lastPerf = null;
|
|
321
|
+
const clearTimer = () => {
|
|
322
|
+
cancelTimer?.();
|
|
323
|
+
cancelTimer = undefined;
|
|
324
|
+
};
|
|
325
|
+
const backOff = (ms) => {
|
|
326
|
+
clearTimer();
|
|
327
|
+
state = 'backing_off';
|
|
328
|
+
cancelTimer = schedule(() => {
|
|
329
|
+
cancelTimer = undefined;
|
|
330
|
+
state = 'idle';
|
|
331
|
+
pump();
|
|
332
|
+
}, ms);
|
|
333
|
+
};
|
|
334
|
+
const retryLater = () => {
|
|
335
|
+
const delay = Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * 2 ** failures);
|
|
336
|
+
failures += 1;
|
|
337
|
+
backOff(delay);
|
|
338
|
+
};
|
|
339
|
+
function pump() {
|
|
340
|
+
if (state === 'disposed' || state === 'stopped' || state === 'backing_off')
|
|
341
|
+
return;
|
|
342
|
+
if (inFlight || !desired)
|
|
343
|
+
return;
|
|
344
|
+
const target = desired;
|
|
345
|
+
const messages = [];
|
|
346
|
+
const revision = nextRevision;
|
|
347
|
+
if (needSnapshot) {
|
|
348
|
+
const entities = [...target.values()];
|
|
349
|
+
const chunks = [[]];
|
|
350
|
+
// Reserve the maximum bridge id (64 characters) and part-number widths.
|
|
351
|
+
// Count each entity once, rather than serializing every growing candidate chunk.
|
|
352
|
+
const overhead = encodedBytes(sceneEnvelope('x'.repeat(64), {
|
|
353
|
+
type: 'host.scene.snapshot',
|
|
354
|
+
payload: {
|
|
355
|
+
sceneVersion: SIM_SCENE_VERSION,
|
|
356
|
+
workspaceId: options.workspaceId,
|
|
357
|
+
revision,
|
|
358
|
+
part: MAX_SCENE_ENTITIES,
|
|
359
|
+
parts: MAX_SCENE_ENTITIES,
|
|
360
|
+
entities: [],
|
|
361
|
+
},
|
|
362
|
+
}));
|
|
363
|
+
let chunk = chunks[0];
|
|
364
|
+
let bytes = overhead;
|
|
365
|
+
for (const entity of entities) {
|
|
366
|
+
const entityBytes = encodedBytes(entity);
|
|
367
|
+
if (overhead + entityBytes > MAX_SCENE_MESSAGE_BYTES)
|
|
368
|
+
throw new Error('Agent Sim scene entity is over its size budget');
|
|
369
|
+
if (chunk.length > 0 &&
|
|
370
|
+
(chunk.length === MAX_SCENE_ENTITIES_PER_MESSAGE ||
|
|
371
|
+
bytes + 1 + entityBytes > MAX_SCENE_MESSAGE_BYTES)) {
|
|
372
|
+
chunk = [];
|
|
373
|
+
chunks.push(chunk);
|
|
374
|
+
bytes = overhead;
|
|
375
|
+
}
|
|
376
|
+
bytes += entityBytes + (chunk.length > 0 ? 1 : 0);
|
|
377
|
+
chunk.push(entity);
|
|
378
|
+
}
|
|
379
|
+
for (const [index, partEntities] of chunks.entries())
|
|
380
|
+
messages.push({
|
|
381
|
+
type: 'host.scene.snapshot',
|
|
382
|
+
payload: {
|
|
383
|
+
sceneVersion: SIM_SCENE_VERSION,
|
|
384
|
+
workspaceId: options.workspaceId,
|
|
385
|
+
revision,
|
|
386
|
+
part: index + 1,
|
|
387
|
+
parts: chunks.length,
|
|
388
|
+
entities: partEntities,
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
const upserts = [];
|
|
394
|
+
const removes = [];
|
|
395
|
+
for (const [id, entity] of target) {
|
|
396
|
+
const old = acked.get(id);
|
|
397
|
+
if (!old || !sameEntity(old, entity))
|
|
398
|
+
upserts.push(entity);
|
|
399
|
+
}
|
|
400
|
+
for (const id of acked.keys())
|
|
401
|
+
if (!target.has(id))
|
|
402
|
+
removes.push(id);
|
|
403
|
+
if (upserts.length === 0 && removes.length === 0) {
|
|
404
|
+
state = 'synced';
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
// A change too big for one message is cheaper as a snapshot than as a split diff.
|
|
408
|
+
if (upserts.length > MAX_SCENE_ENTITIES_PER_MESSAGE ||
|
|
409
|
+
removes.length > MAX_SCENE_ENTITIES_PER_MESSAGE) {
|
|
410
|
+
needSnapshot = true;
|
|
411
|
+
pump();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const message = {
|
|
415
|
+
type: 'host.scene.diff',
|
|
416
|
+
payload: {
|
|
417
|
+
sceneVersion: SIM_SCENE_VERSION,
|
|
418
|
+
workspaceId: options.workspaceId,
|
|
419
|
+
baseRevision: ackedRevision,
|
|
420
|
+
revision,
|
|
421
|
+
upserts,
|
|
422
|
+
removes,
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
if (encodedBytes(sceneEnvelope('x'.repeat(64), message)) > MAX_SCENE_MESSAGE_BYTES) {
|
|
426
|
+
needSnapshot = true;
|
|
427
|
+
pump();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
messages.push(message);
|
|
431
|
+
}
|
|
432
|
+
nextRevision += 1;
|
|
433
|
+
// In flight before the first post: an engine may answer synchronously.
|
|
434
|
+
inFlight = { revision, target };
|
|
435
|
+
state = 'sending';
|
|
436
|
+
clearTimer();
|
|
437
|
+
cancelTimer = schedule(() => {
|
|
438
|
+
cancelTimer = undefined;
|
|
439
|
+
if (!inFlight || inFlight.revision !== revision)
|
|
440
|
+
return;
|
|
441
|
+
counts.timeouts += 1;
|
|
442
|
+
inFlight = null;
|
|
443
|
+
needSnapshot = true;
|
|
444
|
+
retryLater();
|
|
445
|
+
}, ackTimeoutMs);
|
|
446
|
+
if (messages[0].type === 'host.scene.snapshot')
|
|
447
|
+
counts.snapshots += 1;
|
|
448
|
+
else
|
|
449
|
+
counts.diffs += 1;
|
|
450
|
+
try {
|
|
451
|
+
for (const message of messages) {
|
|
452
|
+
// An answer that arrived mid-snapshot ended this revision; stop sending its parts.
|
|
453
|
+
if (inFlight?.revision !== revision && messages.length > 1)
|
|
454
|
+
break;
|
|
455
|
+
options.send(message);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
if (inFlight?.revision === revision) {
|
|
460
|
+
clearTimer();
|
|
461
|
+
inFlight = null;
|
|
462
|
+
needSnapshot = true;
|
|
463
|
+
retryLater();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
update(entities) {
|
|
469
|
+
if (state === 'disposed')
|
|
470
|
+
return;
|
|
471
|
+
const next = new Map(entities.map((entity) => [entity.id, entity]));
|
|
472
|
+
// Acknowledgements must describe exactly the bounded scene sent to the engine.
|
|
473
|
+
desired =
|
|
474
|
+
next.size <= MAX_SCENE_ENTITIES ? next : new Map([...next].slice(0, MAX_SCENE_ENTITIES));
|
|
475
|
+
pump();
|
|
476
|
+
},
|
|
477
|
+
receive(message) {
|
|
478
|
+
if (state === 'disposed')
|
|
479
|
+
return;
|
|
480
|
+
if (message.type === 'frame.scene.perf') {
|
|
481
|
+
lastPerf = message.payload;
|
|
482
|
+
options.onPerf?.(message.payload);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (!inFlight || message.payload.revision !== inFlight.revision) {
|
|
486
|
+
counts.ignoredAcks += 1;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
clearTimer();
|
|
490
|
+
const { revision, target } = inFlight;
|
|
491
|
+
inFlight = null;
|
|
492
|
+
if (message.type === 'frame.scene.ack') {
|
|
493
|
+
acked = new Map(target);
|
|
494
|
+
ackedRevision = revision;
|
|
495
|
+
needSnapshot = false;
|
|
496
|
+
failures = 0;
|
|
497
|
+
state = 'idle';
|
|
498
|
+
pump();
|
|
499
|
+
if (!inFlight && state === 'idle')
|
|
500
|
+
state = 'synced';
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
counts.refusals += 1;
|
|
504
|
+
switch (message.payload.code) {
|
|
505
|
+
case 'version_unsupported':
|
|
506
|
+
state = 'stopped';
|
|
507
|
+
return;
|
|
508
|
+
case 'busy':
|
|
509
|
+
backOff(Math.min(Math.max(message.payload.retryAfterMs ?? BACKOFF_MIN_MS, 100), 10_000));
|
|
510
|
+
return;
|
|
511
|
+
default:
|
|
512
|
+
needSnapshot = true;
|
|
513
|
+
retryLater();
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
stats: () => ({
|
|
517
|
+
state,
|
|
518
|
+
ackedRevision,
|
|
519
|
+
...counts,
|
|
520
|
+
lastPerf,
|
|
521
|
+
}),
|
|
522
|
+
dispose() {
|
|
523
|
+
clearTimer();
|
|
524
|
+
inFlight = null;
|
|
525
|
+
state = 'disposed';
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adea-ai/spatial",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.113.0",
|
|
4
4
|
"description": "Scene and asset manifest contracts plus the shell-to-engine spatial protocol for Adea workspaces: scene mount manifests, spawn URL helpers, and scene telemetry schemas. Pure types — no rendering, no binaries.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"assets",
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
1
|
import { isSerializedSimCharacter } from './sim-bridge/character'
|
|
2
2
|
|
|
3
3
|
export * from './asset-manifests'
|
|
4
|
-
export {
|
|
5
|
-
agentSimEngineManifestUrl,
|
|
6
|
-
isLocalDevHost,
|
|
7
|
-
isOfficialAgentSimWebOrigin,
|
|
8
|
-
parseAgentSimEngineManifest,
|
|
9
|
-
OFFICIAL_AGENT_SIM_WEB_HOSTS,
|
|
10
|
-
type AgentSimEngineManifest,
|
|
11
|
-
type AgentSimEngineSurface,
|
|
12
|
-
} from './engine'
|
|
13
4
|
export { hqHomeManifest, hqWorkManifest } from './manifests'
|
|
14
5
|
export {
|
|
15
6
|
appRouteHref,
|
package/src/sim-bridge/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { OriginAllowlist } from './origin'
|
|
|
3
3
|
import { SIM_SURFACES, type SimSurface } from './launch'
|
|
4
4
|
import { parseSimCharacterValue } from './character'
|
|
5
5
|
import { SIM_UNAVAILABLE_REASONS, type SimUnavailableReason } from './availability'
|
|
6
|
+
import { parseFrameScenePayload, type SimFrameSceneMessage } from './scene'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Versioned message schema between the Adea host window and the hosted Agent Sim frame.
|
|
@@ -59,7 +60,8 @@ export type SimFrameRequestMessage =
|
|
|
59
60
|
payload: Readonly<{ requestId: string; character: string }>
|
|
60
61
|
}>
|
|
61
62
|
|
|
62
|
-
|
|
63
|
+
/** The scene channel's acknowledgements, refusals and measurements (see `scene.ts`). */
|
|
64
|
+
export type SimFrameMessage = SimFrameStatusMessage | SimFrameRequestMessage | SimFrameSceneMessage
|
|
63
65
|
|
|
64
66
|
export type SimHostMessage =
|
|
65
67
|
| Readonly<{
|
|
@@ -209,6 +211,12 @@ export function parseFrameMessage(
|
|
|
209
211
|
? { ok: true, message: { type, payload: { requestId, character } } }
|
|
210
212
|
: fail('invalid_payload')
|
|
211
213
|
}
|
|
214
|
+
case 'frame.scene.ack':
|
|
215
|
+
case 'frame.scene.nack':
|
|
216
|
+
case 'frame.scene.perf': {
|
|
217
|
+
const message = parseFrameScenePayload(type, payload)
|
|
218
|
+
return message ? { ok: true, message } : fail('invalid_payload')
|
|
219
|
+
}
|
|
212
220
|
default:
|
|
213
221
|
return fail('unknown_type')
|
|
214
222
|
}
|