@agentuity/runtime 0.0.107 → 0.0.108
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/_metadata.d.ts.map +1 -1
- package/dist/_metadata.js +7 -0
- package/dist/_metadata.js.map +1 -1
- package/dist/_standalone.d.ts.map +1 -1
- package/dist/_standalone.js +25 -9
- package/dist/_standalone.js.map +1 -1
- package/dist/bun-s3-patch.d.ts +13 -2
- package/dist/bun-s3-patch.d.ts.map +1 -1
- package/dist/bun-s3-patch.js +82 -8
- package/dist/bun-s3-patch.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +7 -4
- package/dist/middleware.js.map +1 -1
- package/dist/services/thread/local.d.ts.map +1 -1
- package/dist/services/thread/local.js +106 -25
- package/dist/services/thread/local.js.map +1 -1
- package/dist/session.d.ts +206 -27
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +386 -69
- package/dist/session.js.map +1 -1
- package/package.json +5 -5
- package/src/_metadata.ts +11 -0
- package/src/_standalone.ts +25 -9
- package/src/bun-s3-patch.ts +138 -10
- package/src/index.ts +6 -0
- package/src/middleware.ts +8 -4
- package/src/services/thread/local.ts +119 -30
- package/src/session.ts +599 -90
package/dist/session.js
CHANGED
|
@@ -233,18 +233,251 @@ export class DefaultThreadIDProvider {
|
|
|
233
233
|
return threadId;
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
|
-
export class
|
|
236
|
+
export class LazyThreadState {
|
|
237
|
+
#status = 'idle';
|
|
238
|
+
#state = new Map();
|
|
239
|
+
#pendingOperations = [];
|
|
237
240
|
#initialStateJson;
|
|
241
|
+
#restoreFn;
|
|
242
|
+
#loadingPromise = null;
|
|
243
|
+
constructor(restoreFn) {
|
|
244
|
+
this.#restoreFn = restoreFn;
|
|
245
|
+
}
|
|
246
|
+
get loaded() {
|
|
247
|
+
return this.#status === 'loaded';
|
|
248
|
+
}
|
|
249
|
+
get dirty() {
|
|
250
|
+
if (this.#status === 'pending-writes') {
|
|
251
|
+
return this.#pendingOperations.length > 0;
|
|
252
|
+
}
|
|
253
|
+
if (this.#status === 'loaded') {
|
|
254
|
+
const currentJson = JSON.stringify(Object.fromEntries(this.#state));
|
|
255
|
+
return currentJson !== this.#initialStateJson;
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
async ensureLoaded() {
|
|
260
|
+
if (this.#status === 'loaded') {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (this.#loadingPromise) {
|
|
264
|
+
await this.#loadingPromise;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
this.#loadingPromise = (async () => {
|
|
268
|
+
try {
|
|
269
|
+
await this.doLoad();
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
this.#loadingPromise = null;
|
|
273
|
+
}
|
|
274
|
+
})();
|
|
275
|
+
await this.#loadingPromise;
|
|
276
|
+
}
|
|
277
|
+
async doLoad() {
|
|
278
|
+
const { state } = await this.#restoreFn();
|
|
279
|
+
// Initialize state from restored data
|
|
280
|
+
this.#state = new Map(state);
|
|
281
|
+
this.#initialStateJson = JSON.stringify(Object.fromEntries(this.#state));
|
|
282
|
+
// Apply any pending operations
|
|
283
|
+
for (const op of this.#pendingOperations) {
|
|
284
|
+
switch (op.op) {
|
|
285
|
+
case 'clear':
|
|
286
|
+
this.#state.clear();
|
|
287
|
+
break;
|
|
288
|
+
case 'set':
|
|
289
|
+
if (op.key !== undefined) {
|
|
290
|
+
this.#state.set(op.key, op.value);
|
|
291
|
+
}
|
|
292
|
+
break;
|
|
293
|
+
case 'delete':
|
|
294
|
+
if (op.key !== undefined) {
|
|
295
|
+
this.#state.delete(op.key);
|
|
296
|
+
}
|
|
297
|
+
break;
|
|
298
|
+
case 'push':
|
|
299
|
+
if (op.key !== undefined) {
|
|
300
|
+
const existing = this.#state.get(op.key);
|
|
301
|
+
if (Array.isArray(existing)) {
|
|
302
|
+
existing.push(op.value);
|
|
303
|
+
// Apply maxRecords limit
|
|
304
|
+
if (op.maxRecords !== undefined && existing.length > op.maxRecords) {
|
|
305
|
+
existing.splice(0, existing.length - op.maxRecords);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
else if (existing === undefined) {
|
|
309
|
+
this.#state.set(op.key, [op.value]);
|
|
310
|
+
}
|
|
311
|
+
// If existing is non-array, silently skip (error would have been thrown if loaded)
|
|
312
|
+
}
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
this.#pendingOperations = [];
|
|
317
|
+
this.#status = 'loaded';
|
|
318
|
+
}
|
|
319
|
+
async get(key) {
|
|
320
|
+
await this.ensureLoaded();
|
|
321
|
+
return this.#state.get(key);
|
|
322
|
+
}
|
|
323
|
+
async set(key, value) {
|
|
324
|
+
if (this.#status === 'loaded') {
|
|
325
|
+
this.#state.set(key, value);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
this.#pendingOperations.push({ op: 'set', key, value });
|
|
329
|
+
if (this.#status === 'idle') {
|
|
330
|
+
this.#status = 'pending-writes';
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async has(key) {
|
|
335
|
+
await this.ensureLoaded();
|
|
336
|
+
return this.#state.has(key);
|
|
337
|
+
}
|
|
338
|
+
async delete(key) {
|
|
339
|
+
if (this.#status === 'loaded') {
|
|
340
|
+
this.#state.delete(key);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
this.#pendingOperations.push({ op: 'delete', key });
|
|
344
|
+
if (this.#status === 'idle') {
|
|
345
|
+
this.#status = 'pending-writes';
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
async clear() {
|
|
350
|
+
if (this.#status === 'loaded') {
|
|
351
|
+
this.#state.clear();
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
// Clear replaces all previous pending operations
|
|
355
|
+
this.#pendingOperations = [{ op: 'clear' }];
|
|
356
|
+
if (this.#status === 'idle') {
|
|
357
|
+
this.#status = 'pending-writes';
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async entries() {
|
|
362
|
+
await this.ensureLoaded();
|
|
363
|
+
return Array.from(this.#state.entries());
|
|
364
|
+
}
|
|
365
|
+
async keys() {
|
|
366
|
+
await this.ensureLoaded();
|
|
367
|
+
return Array.from(this.#state.keys());
|
|
368
|
+
}
|
|
369
|
+
async values() {
|
|
370
|
+
await this.ensureLoaded();
|
|
371
|
+
return Array.from(this.#state.values());
|
|
372
|
+
}
|
|
373
|
+
async size() {
|
|
374
|
+
await this.ensureLoaded();
|
|
375
|
+
return this.#state.size;
|
|
376
|
+
}
|
|
377
|
+
async push(key, value, maxRecords) {
|
|
378
|
+
if (this.#status === 'loaded') {
|
|
379
|
+
// When loaded, push to local array
|
|
380
|
+
const existing = this.#state.get(key);
|
|
381
|
+
if (Array.isArray(existing)) {
|
|
382
|
+
existing.push(value);
|
|
383
|
+
// Apply maxRecords limit
|
|
384
|
+
if (maxRecords !== undefined && existing.length > maxRecords) {
|
|
385
|
+
existing.splice(0, existing.length - maxRecords);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
else if (existing === undefined) {
|
|
389
|
+
this.#state.set(key, [value]);
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
throw new Error(`Cannot push to non-array value at key "${key}"`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
// Queue push operation for merge
|
|
397
|
+
const op = { op: 'push', key, value };
|
|
398
|
+
if (maxRecords !== undefined) {
|
|
399
|
+
op.maxRecords = maxRecords;
|
|
400
|
+
}
|
|
401
|
+
this.#pendingOperations.push(op);
|
|
402
|
+
if (this.#status === 'idle') {
|
|
403
|
+
this.#status = 'pending-writes';
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Get the current status for save logic
|
|
409
|
+
* @internal
|
|
410
|
+
*/
|
|
411
|
+
getStatus() {
|
|
412
|
+
return this.#status;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Get pending operations for merge command
|
|
416
|
+
* @internal
|
|
417
|
+
*/
|
|
418
|
+
getPendingOperations() {
|
|
419
|
+
return [...this.#pendingOperations];
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Get serialized state for full save.
|
|
423
|
+
* Ensures state is loaded before serializing.
|
|
424
|
+
* @internal
|
|
425
|
+
*/
|
|
426
|
+
async getSerializedState() {
|
|
427
|
+
await this.ensureLoaded();
|
|
428
|
+
return Object.fromEntries(this.#state);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
export class DefaultThread {
|
|
238
432
|
id;
|
|
239
433
|
state;
|
|
240
|
-
metadata;
|
|
434
|
+
#metadata = null;
|
|
435
|
+
#metadataDirty = false;
|
|
436
|
+
#metadataLoadPromise = null;
|
|
241
437
|
provider;
|
|
242
|
-
|
|
438
|
+
#restoreFn;
|
|
439
|
+
#restoredMetadata;
|
|
440
|
+
constructor(provider, id, restoreFn, initialMetadata) {
|
|
243
441
|
this.provider = provider;
|
|
244
442
|
this.id = id;
|
|
245
|
-
this
|
|
246
|
-
this.#
|
|
247
|
-
this.
|
|
443
|
+
this.#restoreFn = restoreFn;
|
|
444
|
+
this.#restoredMetadata = initialMetadata;
|
|
445
|
+
this.state = new LazyThreadState(restoreFn);
|
|
446
|
+
}
|
|
447
|
+
async ensureMetadataLoaded() {
|
|
448
|
+
if (this.#metadata !== null) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
// If we have initial metadata from thread creation, use it
|
|
452
|
+
if (this.#restoredMetadata !== undefined) {
|
|
453
|
+
this.#metadata = this.#restoredMetadata;
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (this.#metadataLoadPromise) {
|
|
457
|
+
await this.#metadataLoadPromise;
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
this.#metadataLoadPromise = (async () => {
|
|
461
|
+
try {
|
|
462
|
+
await this.doLoadMetadata();
|
|
463
|
+
}
|
|
464
|
+
finally {
|
|
465
|
+
this.#metadataLoadPromise = null;
|
|
466
|
+
}
|
|
467
|
+
})();
|
|
468
|
+
await this.#metadataLoadPromise;
|
|
469
|
+
}
|
|
470
|
+
async doLoadMetadata() {
|
|
471
|
+
const { metadata } = await this.#restoreFn();
|
|
472
|
+
this.#metadata = metadata;
|
|
473
|
+
}
|
|
474
|
+
async getMetadata() {
|
|
475
|
+
await this.ensureMetadataLoaded();
|
|
476
|
+
return { ...this.#metadata };
|
|
477
|
+
}
|
|
478
|
+
async setMetadata(metadata) {
|
|
479
|
+
this.#metadata = metadata;
|
|
480
|
+
this.#metadataDirty = true;
|
|
248
481
|
}
|
|
249
482
|
addEventListener(eventName, callback) {
|
|
250
483
|
let listeners = threadEventListeners.get(this);
|
|
@@ -275,38 +508,79 @@ export class DefaultThread {
|
|
|
275
508
|
await this.provider.destroy(this);
|
|
276
509
|
}
|
|
277
510
|
/**
|
|
278
|
-
* Check if thread
|
|
511
|
+
* Check if thread has any data (state or metadata)
|
|
512
|
+
*/
|
|
513
|
+
async empty() {
|
|
514
|
+
const stateSize = await this.state.size();
|
|
515
|
+
// Check both loaded metadata and initial metadata from constructor
|
|
516
|
+
const meta = this.#metadata ?? this.#restoredMetadata ?? {};
|
|
517
|
+
return stateSize === 0 && Object.keys(meta).length === 0;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Check if thread needs saving
|
|
279
521
|
* @internal
|
|
280
522
|
*/
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
523
|
+
needsSave() {
|
|
524
|
+
return this.state.dirty || this.#metadataDirty;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Get the save mode for this thread
|
|
528
|
+
* @internal
|
|
529
|
+
*/
|
|
530
|
+
getSaveMode() {
|
|
531
|
+
const stateStatus = this.state.getStatus();
|
|
532
|
+
if (stateStatus === 'idle' && !this.#metadataDirty) {
|
|
533
|
+
return 'none';
|
|
534
|
+
}
|
|
535
|
+
if (stateStatus === 'pending-writes') {
|
|
536
|
+
return 'merge';
|
|
537
|
+
}
|
|
538
|
+
if (stateStatus === 'loaded' && (this.state.dirty || this.#metadataDirty)) {
|
|
539
|
+
return 'full';
|
|
284
540
|
}
|
|
285
|
-
|
|
286
|
-
|
|
541
|
+
// Only metadata was changed without loading state
|
|
542
|
+
if (this.#metadataDirty) {
|
|
543
|
+
return 'merge';
|
|
544
|
+
}
|
|
545
|
+
return 'none';
|
|
287
546
|
}
|
|
288
547
|
/**
|
|
289
|
-
*
|
|
548
|
+
* Get pending operations for merge command
|
|
549
|
+
* @internal
|
|
550
|
+
*/
|
|
551
|
+
getPendingOperations() {
|
|
552
|
+
return this.state.getPendingOperations();
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Get metadata for saving (returns null if not loaded/modified)
|
|
556
|
+
* @internal
|
|
290
557
|
*/
|
|
291
|
-
|
|
292
|
-
|
|
558
|
+
getMetadataForSave() {
|
|
559
|
+
if (this.#metadataDirty && this.#metadata) {
|
|
560
|
+
return this.#metadata;
|
|
561
|
+
}
|
|
562
|
+
return undefined;
|
|
293
563
|
}
|
|
294
564
|
/**
|
|
295
|
-
* Get serialized state for
|
|
565
|
+
* Get serialized state for full save.
|
|
566
|
+
* Ensures state is loaded before serializing.
|
|
296
567
|
* @internal
|
|
297
568
|
*/
|
|
298
|
-
getSerializedState() {
|
|
299
|
-
const
|
|
300
|
-
|
|
569
|
+
async getSerializedState() {
|
|
570
|
+
const state = await this.state.getSerializedState();
|
|
571
|
+
// Also ensure metadata is loaded
|
|
572
|
+
const meta = this.#metadata ?? this.#restoredMetadata ?? {};
|
|
573
|
+
const hasState = Object.keys(state).length > 0;
|
|
574
|
+
const hasMetadata = Object.keys(meta).length > 0;
|
|
301
575
|
if (!hasState && !hasMetadata) {
|
|
302
576
|
return '';
|
|
303
577
|
}
|
|
304
578
|
const data = {};
|
|
305
579
|
if (hasState) {
|
|
306
|
-
data.state =
|
|
580
|
+
data.state = state;
|
|
307
581
|
}
|
|
308
582
|
if (hasMetadata) {
|
|
309
|
-
data.metadata =
|
|
583
|
+
data.metadata = meta;
|
|
310
584
|
}
|
|
311
585
|
return JSON.stringify(data);
|
|
312
586
|
}
|
|
@@ -625,6 +899,42 @@ export class ThreadWebSocketClient {
|
|
|
625
899
|
}, this.requestTimeoutMs);
|
|
626
900
|
});
|
|
627
901
|
}
|
|
902
|
+
async merge(threadId, operations, metadata) {
|
|
903
|
+
// Wait for connection/reconnection if in progress
|
|
904
|
+
if (this.wsConnecting) {
|
|
905
|
+
await this.wsConnecting;
|
|
906
|
+
}
|
|
907
|
+
if (!this.authenticated || !this.ws) {
|
|
908
|
+
throw new Error('WebSocket not connected or authenticated');
|
|
909
|
+
}
|
|
910
|
+
return new Promise((resolve, reject) => {
|
|
911
|
+
const requestId = crypto.randomUUID();
|
|
912
|
+
this.pendingRequests.set(requestId, {
|
|
913
|
+
resolve: () => resolve(),
|
|
914
|
+
reject,
|
|
915
|
+
});
|
|
916
|
+
const data = {
|
|
917
|
+
thread_id: threadId,
|
|
918
|
+
operations,
|
|
919
|
+
};
|
|
920
|
+
if (metadata && Object.keys(metadata).length > 0) {
|
|
921
|
+
data.metadata = metadata;
|
|
922
|
+
}
|
|
923
|
+
const message = {
|
|
924
|
+
id: requestId,
|
|
925
|
+
action: 'merge',
|
|
926
|
+
data,
|
|
927
|
+
};
|
|
928
|
+
this.ws.send(JSON.stringify(message));
|
|
929
|
+
// Timeout after configured duration
|
|
930
|
+
setTimeout(() => {
|
|
931
|
+
if (this.pendingRequests.has(requestId)) {
|
|
932
|
+
this.pendingRequests.delete(requestId);
|
|
933
|
+
reject(new Error('Request timeout'));
|
|
934
|
+
}
|
|
935
|
+
}, this.requestTimeoutMs);
|
|
936
|
+
});
|
|
937
|
+
}
|
|
628
938
|
cleanup() {
|
|
629
939
|
// Mark as disposed to prevent new reconnection attempts
|
|
630
940
|
this.isDisposed = true;
|
|
@@ -680,79 +990,86 @@ export class DefaultThreadProvider {
|
|
|
680
990
|
async restore(ctx) {
|
|
681
991
|
const threadId = await this.threadIDProvider.getThreadId(this.appState, ctx);
|
|
682
992
|
validateThreadIdOrThrow(threadId);
|
|
683
|
-
internal.info('[thread]
|
|
684
|
-
//
|
|
685
|
-
|
|
686
|
-
internal.info('[thread]
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
993
|
+
internal.info('[thread] creating lazy thread %s (no eager restore)', threadId);
|
|
994
|
+
// Create a restore function that will be called lazily when state/metadata is accessed
|
|
995
|
+
const restoreFn = async () => {
|
|
996
|
+
internal.info('[thread] lazy loading state for thread %s', threadId);
|
|
997
|
+
// Wait for WebSocket connection if still connecting
|
|
998
|
+
if (this.wsConnecting) {
|
|
999
|
+
internal.info('[thread] waiting for WebSocket connection');
|
|
1000
|
+
await this.wsConnecting;
|
|
1001
|
+
}
|
|
1002
|
+
if (!this.wsClient) {
|
|
1003
|
+
internal.info('[thread] no WebSocket client available, returning empty state');
|
|
1004
|
+
return { state: new Map(), metadata: {} };
|
|
1005
|
+
}
|
|
693
1006
|
try {
|
|
694
|
-
internal.info('[thread] restoring state from WebSocket');
|
|
695
1007
|
const restoredData = await this.wsClient.restore(threadId);
|
|
696
1008
|
if (restoredData) {
|
|
697
1009
|
internal.info('[thread] restored state: %d bytes', restoredData.length);
|
|
698
1010
|
const { flatStateJson, metadata } = parseThreadData(restoredData);
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1011
|
+
const state = new Map();
|
|
1012
|
+
if (flatStateJson) {
|
|
1013
|
+
try {
|
|
1014
|
+
const data = JSON.parse(flatStateJson);
|
|
1015
|
+
for (const [key, value] of Object.entries(data)) {
|
|
1016
|
+
state.set(key, value);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
catch {
|
|
1020
|
+
internal.info('[thread] failed to parse state JSON');
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return { state, metadata: metadata || {} };
|
|
704
1024
|
}
|
|
1025
|
+
internal.info('[thread] no existing state found');
|
|
1026
|
+
return { state: new Map(), metadata: {} };
|
|
705
1027
|
}
|
|
706
1028
|
catch (err) {
|
|
707
1029
|
internal.info('[thread] WebSocket restore failed: %s', err);
|
|
708
|
-
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
else {
|
|
712
|
-
internal.info('[thread] no WebSocket client available');
|
|
713
|
-
}
|
|
714
|
-
const thread = new DefaultThread(this, threadId, initialStateJson, restoredMetadata);
|
|
715
|
-
// Populate thread state from restored data
|
|
716
|
-
if (initialStateJson) {
|
|
717
|
-
try {
|
|
718
|
-
const data = JSON.parse(initialStateJson);
|
|
719
|
-
for (const [key, value] of Object.entries(data)) {
|
|
720
|
-
thread.state.set(key, value);
|
|
721
|
-
}
|
|
722
|
-
internal.info('[thread] populated state with %d keys', thread.state.size);
|
|
1030
|
+
return { state: new Map(), metadata: {} };
|
|
723
1031
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
// Continue with empty state if parsing fails
|
|
727
|
-
}
|
|
728
|
-
}
|
|
1032
|
+
};
|
|
1033
|
+
const thread = new DefaultThread(this, threadId, restoreFn);
|
|
729
1034
|
await fireEvent('thread.created', thread);
|
|
730
1035
|
return thread;
|
|
731
1036
|
}
|
|
732
1037
|
async save(thread) {
|
|
733
1038
|
if (thread instanceof DefaultThread) {
|
|
734
|
-
|
|
1039
|
+
const saveMode = thread.getSaveMode();
|
|
1040
|
+
internal.info('[thread] DefaultThreadProvider.save() - thread %s, saveMode: %s, hasWsClient: %s', thread.id, saveMode, !!this.wsClient);
|
|
1041
|
+
if (saveMode === 'none') {
|
|
1042
|
+
internal.info('[thread] skipping save - no changes');
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
735
1045
|
// Wait for WebSocket connection if still connecting
|
|
736
1046
|
if (this.wsConnecting) {
|
|
737
1047
|
internal.info('[thread] waiting for WebSocket connection');
|
|
738
1048
|
await this.wsConnecting;
|
|
739
1049
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
1050
|
+
if (!this.wsClient) {
|
|
1051
|
+
internal.info('[thread] no WebSocket client available, skipping save');
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
try {
|
|
1055
|
+
if (saveMode === 'merge') {
|
|
1056
|
+
const operations = thread.getPendingOperations();
|
|
1057
|
+
const metadata = thread.getMetadataForSave();
|
|
1058
|
+
internal.info('[thread] sending merge command with %d operations', operations.length);
|
|
1059
|
+
await this.wsClient.merge(thread.id, operations, metadata);
|
|
1060
|
+
internal.info('[thread] WebSocket merge completed');
|
|
1061
|
+
}
|
|
1062
|
+
else if (saveMode === 'full') {
|
|
1063
|
+
const serialized = await thread.getSerializedState();
|
|
744
1064
|
internal.info('[thread] saving to WebSocket, serialized length: %d', serialized.length);
|
|
745
|
-
const metadata =
|
|
1065
|
+
const metadata = thread.getMetadataForSave();
|
|
746
1066
|
await this.wsClient.save(thread.id, serialized, metadata);
|
|
747
1067
|
internal.info('[thread] WebSocket save completed');
|
|
748
1068
|
}
|
|
749
|
-
catch (err) {
|
|
750
|
-
internal.info('[thread] WebSocket save failed: %s', err);
|
|
751
|
-
// Don't throw - allow request to complete even if save fails
|
|
752
|
-
}
|
|
753
1069
|
}
|
|
754
|
-
|
|
755
|
-
internal.info('[thread]
|
|
1070
|
+
catch (err) {
|
|
1071
|
+
internal.info('[thread] WebSocket save/merge failed: %s', err);
|
|
1072
|
+
// Don't throw - allow request to complete even if save fails
|
|
756
1073
|
}
|
|
757
1074
|
}
|
|
758
1075
|
}
|