@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/src/session.ts
CHANGED
|
@@ -44,6 +44,127 @@ export function parseThreadData(raw: string | undefined): ParsedThreadData {
|
|
|
44
44
|
export type ThreadEventName = 'destroyed';
|
|
45
45
|
export type SessionEventName = 'completed';
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Represents a merge operation for thread state.
|
|
49
|
+
* Used when state is modified without being loaded first.
|
|
50
|
+
*/
|
|
51
|
+
export interface MergeOperation {
|
|
52
|
+
op: 'set' | 'delete' | 'clear' | 'push';
|
|
53
|
+
key?: string;
|
|
54
|
+
value?: unknown;
|
|
55
|
+
maxRecords?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Async thread state storage with lazy loading.
|
|
60
|
+
*
|
|
61
|
+
* State is only fetched from storage when first accessed via a read operation.
|
|
62
|
+
* Write operations can be batched and sent as a merge command without loading.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* // Read triggers lazy load
|
|
67
|
+
* const count = await ctx.thread.state.get<number>('messageCount');
|
|
68
|
+
*
|
|
69
|
+
* // Write queues operation (may not trigger load)
|
|
70
|
+
* await ctx.thread.state.set('messageCount', (count ?? 0) + 1);
|
|
71
|
+
*
|
|
72
|
+
* // Check state status
|
|
73
|
+
* if (ctx.thread.state.dirty) {
|
|
74
|
+
* console.log('State has pending changes');
|
|
75
|
+
* }
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export interface ThreadState {
|
|
79
|
+
/**
|
|
80
|
+
* Whether state has been loaded from storage.
|
|
81
|
+
* True when state has been fetched via a read operation.
|
|
82
|
+
*/
|
|
83
|
+
readonly loaded: boolean;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether state has pending changes.
|
|
87
|
+
* True when there are queued writes (pending-writes state) or
|
|
88
|
+
* modifications after loading (loaded state with changes).
|
|
89
|
+
*/
|
|
90
|
+
readonly dirty: boolean;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get a value from thread state.
|
|
94
|
+
* Triggers lazy load if state hasn't been fetched yet.
|
|
95
|
+
*/
|
|
96
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Set a value in thread state.
|
|
100
|
+
* If state hasn't been loaded, queues the operation for merge.
|
|
101
|
+
*/
|
|
102
|
+
set<T = unknown>(key: string, value: T): Promise<void>;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check if a key exists in thread state.
|
|
106
|
+
* Triggers lazy load if state hasn't been fetched yet.
|
|
107
|
+
*/
|
|
108
|
+
has(key: string): Promise<boolean>;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Delete a key from thread state.
|
|
112
|
+
* If state hasn't been loaded, queues the operation for merge.
|
|
113
|
+
*/
|
|
114
|
+
delete(key: string): Promise<void>;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Clear all thread state.
|
|
118
|
+
* If state hasn't been loaded, queues a clear operation for merge.
|
|
119
|
+
*/
|
|
120
|
+
clear(): Promise<void>;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get all entries as key-value pairs.
|
|
124
|
+
* Triggers lazy load if state hasn't been fetched yet.
|
|
125
|
+
*/
|
|
126
|
+
entries<T = unknown>(): Promise<[string, T][]>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get all keys.
|
|
130
|
+
* Triggers lazy load if state hasn't been fetched yet.
|
|
131
|
+
*/
|
|
132
|
+
keys(): Promise<string[]>;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get all values.
|
|
136
|
+
* Triggers lazy load if state hasn't been fetched yet.
|
|
137
|
+
*/
|
|
138
|
+
values<T = unknown>(): Promise<T[]>;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get the number of entries in state.
|
|
142
|
+
* Triggers lazy load if state hasn't been fetched yet.
|
|
143
|
+
*/
|
|
144
|
+
size(): Promise<number>;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Push a value to an array in thread state.
|
|
148
|
+
* If the key doesn't exist, creates a new array with the value.
|
|
149
|
+
* If state hasn't been loaded, queues the operation for efficient merge.
|
|
150
|
+
*
|
|
151
|
+
* @param key - The key of the array to push to
|
|
152
|
+
* @param value - The value to push
|
|
153
|
+
* @param maxRecords - Optional maximum number of records to keep (sliding window)
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```typescript
|
|
157
|
+
* // Efficiently append messages without loading entire array
|
|
158
|
+
* await ctx.thread.state.push('messages', { role: 'user', content: 'Hello' });
|
|
159
|
+
* await ctx.thread.state.push('messages', { role: 'assistant', content: 'Hi!' });
|
|
160
|
+
*
|
|
161
|
+
* // Keep only the last 100 messages
|
|
162
|
+
* await ctx.thread.state.push('messages', newMessage, 100);
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
push<T = unknown>(key: string, value: T, maxRecords?: number): Promise<void>;
|
|
166
|
+
}
|
|
167
|
+
|
|
47
168
|
type ThreadEventCallback<T extends Thread> = (
|
|
48
169
|
eventName: 'destroyed',
|
|
49
170
|
thread: T
|
|
@@ -70,9 +191,12 @@ type SessionEventCallback<T extends Session> = (
|
|
|
70
191
|
* ctx.logger.info('Thread: %s', ctx.thread.id);
|
|
71
192
|
*
|
|
72
193
|
* // Store data in thread state (persists across sessions)
|
|
73
|
-
* ctx.thread.state.
|
|
74
|
-
*
|
|
75
|
-
*
|
|
194
|
+
* const count = await ctx.thread.state.get<number>('conversationCount') ?? 0;
|
|
195
|
+
* await ctx.thread.state.set('conversationCount', count + 1);
|
|
196
|
+
*
|
|
197
|
+
* // Access metadata
|
|
198
|
+
* const meta = await ctx.thread.getMetadata();
|
|
199
|
+
* await ctx.thread.setMetadata({ ...meta, lastAccess: Date.now() });
|
|
76
200
|
*
|
|
77
201
|
* // Listen for thread destruction
|
|
78
202
|
* ctx.thread.addEventListener('destroyed', (eventName, thread) => {
|
|
@@ -92,31 +216,40 @@ export interface Thread {
|
|
|
92
216
|
id: string;
|
|
93
217
|
|
|
94
218
|
/**
|
|
95
|
-
* Thread-scoped state storage
|
|
96
|
-
*
|
|
219
|
+
* Thread-scoped state storage with async lazy-loading.
|
|
220
|
+
* State is only fetched from storage when first accessed via a read operation.
|
|
97
221
|
*
|
|
98
222
|
* @example
|
|
99
223
|
* ```typescript
|
|
100
|
-
* //
|
|
101
|
-
* ctx.thread.state.
|
|
102
|
-
*
|
|
103
|
-
* );
|
|
224
|
+
* // Read triggers lazy load
|
|
225
|
+
* const count = await ctx.thread.state.get<number>('messageCount');
|
|
226
|
+
* // Write may queue operation without loading
|
|
227
|
+
* await ctx.thread.state.set('messageCount', (count ?? 0) + 1);
|
|
104
228
|
* ```
|
|
105
229
|
*/
|
|
106
|
-
state:
|
|
230
|
+
state: ThreadState;
|
|
107
231
|
|
|
108
232
|
/**
|
|
109
|
-
*
|
|
110
|
-
* Unlike state, metadata is stored
|
|
111
|
-
* for efficient filtering. Initialized to empty object, only persisted if non-empty.
|
|
233
|
+
* Get thread metadata (lazy-loaded).
|
|
234
|
+
* Unlike state, metadata is stored unencrypted for efficient filtering.
|
|
112
235
|
*
|
|
113
236
|
* @example
|
|
114
237
|
* ```typescript
|
|
115
|
-
* ctx.thread.
|
|
116
|
-
*
|
|
238
|
+
* const meta = await ctx.thread.getMetadata();
|
|
239
|
+
* console.log(meta.userId);
|
|
117
240
|
* ```
|
|
118
241
|
*/
|
|
119
|
-
|
|
242
|
+
getMetadata(): Promise<Record<string, unknown>>;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Set thread metadata (full replace).
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* ```typescript
|
|
249
|
+
* await ctx.thread.setMetadata({ userId: 'user123', department: 'sales' });
|
|
250
|
+
* ```
|
|
251
|
+
*/
|
|
252
|
+
setMetadata(metadata: Record<string, unknown>): Promise<void>;
|
|
120
253
|
|
|
121
254
|
/**
|
|
122
255
|
* Register an event listener for when the thread is destroyed.
|
|
@@ -163,15 +296,16 @@ export interface Thread {
|
|
|
163
296
|
/**
|
|
164
297
|
* Check if the thread has any data.
|
|
165
298
|
* Returns true if thread state is empty (no data to save).
|
|
299
|
+
* This is async because it may need to check lazy-loaded state.
|
|
166
300
|
*
|
|
167
301
|
* @example
|
|
168
302
|
* ```typescript
|
|
169
|
-
* if (ctx.thread.empty()) {
|
|
303
|
+
* if (await ctx.thread.empty()) {
|
|
170
304
|
* // Thread has no data, won't be persisted
|
|
171
305
|
* }
|
|
172
306
|
* ```
|
|
173
307
|
*/
|
|
174
|
-
empty(): boolean
|
|
308
|
+
empty(): Promise<boolean>;
|
|
175
309
|
}
|
|
176
310
|
|
|
177
311
|
/**
|
|
@@ -722,24 +856,285 @@ export class DefaultThreadIDProvider implements ThreadIDProvider {
|
|
|
722
856
|
}
|
|
723
857
|
}
|
|
724
858
|
|
|
725
|
-
|
|
859
|
+
type LazyStateStatus = 'idle' | 'pending-writes' | 'loaded';
|
|
860
|
+
|
|
861
|
+
type RestoreFn = () => Promise<{ state: Map<string, unknown>; metadata: Record<string, unknown> }>;
|
|
862
|
+
|
|
863
|
+
export class LazyThreadState implements ThreadState {
|
|
864
|
+
#status: LazyStateStatus = 'idle';
|
|
865
|
+
#state: Map<string, unknown> = new Map();
|
|
866
|
+
#pendingOperations: MergeOperation[] = [];
|
|
726
867
|
#initialStateJson: string | undefined;
|
|
868
|
+
#restoreFn: RestoreFn;
|
|
869
|
+
#loadingPromise: Promise<void> | null = null;
|
|
870
|
+
|
|
871
|
+
constructor(restoreFn: RestoreFn) {
|
|
872
|
+
this.#restoreFn = restoreFn;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
get loaded(): boolean {
|
|
876
|
+
return this.#status === 'loaded';
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
get dirty(): boolean {
|
|
880
|
+
if (this.#status === 'pending-writes') {
|
|
881
|
+
return this.#pendingOperations.length > 0;
|
|
882
|
+
}
|
|
883
|
+
if (this.#status === 'loaded') {
|
|
884
|
+
const currentJson = JSON.stringify(Object.fromEntries(this.#state));
|
|
885
|
+
return currentJson !== this.#initialStateJson;
|
|
886
|
+
}
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private async ensureLoaded(): Promise<void> {
|
|
891
|
+
if (this.#status === 'loaded') {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (this.#loadingPromise) {
|
|
896
|
+
await this.#loadingPromise;
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
this.#loadingPromise = (async () => {
|
|
901
|
+
try {
|
|
902
|
+
await this.doLoad();
|
|
903
|
+
} finally {
|
|
904
|
+
this.#loadingPromise = null;
|
|
905
|
+
}
|
|
906
|
+
})();
|
|
907
|
+
|
|
908
|
+
await this.#loadingPromise;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
private async doLoad(): Promise<void> {
|
|
912
|
+
const { state } = await this.#restoreFn();
|
|
913
|
+
|
|
914
|
+
// Initialize state from restored data
|
|
915
|
+
this.#state = new Map(state);
|
|
916
|
+
this.#initialStateJson = JSON.stringify(Object.fromEntries(this.#state));
|
|
917
|
+
|
|
918
|
+
// Apply any pending operations
|
|
919
|
+
for (const op of this.#pendingOperations) {
|
|
920
|
+
switch (op.op) {
|
|
921
|
+
case 'clear':
|
|
922
|
+
this.#state.clear();
|
|
923
|
+
break;
|
|
924
|
+
case 'set':
|
|
925
|
+
if (op.key !== undefined) {
|
|
926
|
+
this.#state.set(op.key, op.value);
|
|
927
|
+
}
|
|
928
|
+
break;
|
|
929
|
+
case 'delete':
|
|
930
|
+
if (op.key !== undefined) {
|
|
931
|
+
this.#state.delete(op.key);
|
|
932
|
+
}
|
|
933
|
+
break;
|
|
934
|
+
case 'push':
|
|
935
|
+
if (op.key !== undefined) {
|
|
936
|
+
const existing = this.#state.get(op.key);
|
|
937
|
+
if (Array.isArray(existing)) {
|
|
938
|
+
existing.push(op.value);
|
|
939
|
+
// Apply maxRecords limit
|
|
940
|
+
if (op.maxRecords !== undefined && existing.length > op.maxRecords) {
|
|
941
|
+
existing.splice(0, existing.length - op.maxRecords);
|
|
942
|
+
}
|
|
943
|
+
} else if (existing === undefined) {
|
|
944
|
+
this.#state.set(op.key, [op.value]);
|
|
945
|
+
}
|
|
946
|
+
// If existing is non-array, silently skip (error would have been thrown if loaded)
|
|
947
|
+
}
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
this.#pendingOperations = [];
|
|
953
|
+
this.#status = 'loaded';
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async get<T = unknown>(key: string): Promise<T | undefined> {
|
|
957
|
+
await this.ensureLoaded();
|
|
958
|
+
return this.#state.get(key) as T | undefined;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async set<T = unknown>(key: string, value: T): Promise<void> {
|
|
962
|
+
if (this.#status === 'loaded') {
|
|
963
|
+
this.#state.set(key, value);
|
|
964
|
+
} else {
|
|
965
|
+
this.#pendingOperations.push({ op: 'set', key, value });
|
|
966
|
+
if (this.#status === 'idle') {
|
|
967
|
+
this.#status = 'pending-writes';
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async has(key: string): Promise<boolean> {
|
|
973
|
+
await this.ensureLoaded();
|
|
974
|
+
return this.#state.has(key);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async delete(key: string): Promise<void> {
|
|
978
|
+
if (this.#status === 'loaded') {
|
|
979
|
+
this.#state.delete(key);
|
|
980
|
+
} else {
|
|
981
|
+
this.#pendingOperations.push({ op: 'delete', key });
|
|
982
|
+
if (this.#status === 'idle') {
|
|
983
|
+
this.#status = 'pending-writes';
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
async clear(): Promise<void> {
|
|
989
|
+
if (this.#status === 'loaded') {
|
|
990
|
+
this.#state.clear();
|
|
991
|
+
} else {
|
|
992
|
+
// Clear replaces all previous pending operations
|
|
993
|
+
this.#pendingOperations = [{ op: 'clear' }];
|
|
994
|
+
if (this.#status === 'idle') {
|
|
995
|
+
this.#status = 'pending-writes';
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
async entries<T = unknown>(): Promise<[string, T][]> {
|
|
1001
|
+
await this.ensureLoaded();
|
|
1002
|
+
return Array.from(this.#state.entries()) as [string, T][];
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async keys(): Promise<string[]> {
|
|
1006
|
+
await this.ensureLoaded();
|
|
1007
|
+
return Array.from(this.#state.keys());
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
async values<T = unknown>(): Promise<T[]> {
|
|
1011
|
+
await this.ensureLoaded();
|
|
1012
|
+
return Array.from(this.#state.values()) as T[];
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async size(): Promise<number> {
|
|
1016
|
+
await this.ensureLoaded();
|
|
1017
|
+
return this.#state.size;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async push<T = unknown>(key: string, value: T, maxRecords?: number): Promise<void> {
|
|
1021
|
+
if (this.#status === 'loaded') {
|
|
1022
|
+
// When loaded, push to local array
|
|
1023
|
+
const existing = this.#state.get(key);
|
|
1024
|
+
if (Array.isArray(existing)) {
|
|
1025
|
+
existing.push(value);
|
|
1026
|
+
// Apply maxRecords limit
|
|
1027
|
+
if (maxRecords !== undefined && existing.length > maxRecords) {
|
|
1028
|
+
existing.splice(0, existing.length - maxRecords);
|
|
1029
|
+
}
|
|
1030
|
+
} else if (existing === undefined) {
|
|
1031
|
+
this.#state.set(key, [value]);
|
|
1032
|
+
} else {
|
|
1033
|
+
throw new Error(`Cannot push to non-array value at key "${key}"`);
|
|
1034
|
+
}
|
|
1035
|
+
} else {
|
|
1036
|
+
// Queue push operation for merge
|
|
1037
|
+
const op: MergeOperation = { op: 'push', key, value };
|
|
1038
|
+
if (maxRecords !== undefined) {
|
|
1039
|
+
op.maxRecords = maxRecords;
|
|
1040
|
+
}
|
|
1041
|
+
this.#pendingOperations.push(op);
|
|
1042
|
+
if (this.#status === 'idle') {
|
|
1043
|
+
this.#status = 'pending-writes';
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Get the current status for save logic
|
|
1050
|
+
* @internal
|
|
1051
|
+
*/
|
|
1052
|
+
getStatus(): LazyStateStatus {
|
|
1053
|
+
return this.#status;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Get pending operations for merge command
|
|
1058
|
+
* @internal
|
|
1059
|
+
*/
|
|
1060
|
+
getPendingOperations(): MergeOperation[] {
|
|
1061
|
+
return [...this.#pendingOperations];
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Get serialized state for full save.
|
|
1066
|
+
* Ensures state is loaded before serializing.
|
|
1067
|
+
* @internal
|
|
1068
|
+
*/
|
|
1069
|
+
async getSerializedState(): Promise<Record<string, unknown>> {
|
|
1070
|
+
await this.ensureLoaded();
|
|
1071
|
+
return Object.fromEntries(this.#state);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
export class DefaultThread implements Thread {
|
|
727
1076
|
readonly id: string;
|
|
728
|
-
readonly state:
|
|
729
|
-
metadata: Record<string, unknown
|
|
1077
|
+
readonly state: LazyThreadState;
|
|
1078
|
+
#metadata: Record<string, unknown> | null = null;
|
|
1079
|
+
#metadataDirty = false;
|
|
1080
|
+
#metadataLoadPromise: Promise<void> | null = null;
|
|
730
1081
|
private provider: ThreadProvider;
|
|
1082
|
+
#restoreFn: RestoreFn;
|
|
1083
|
+
#restoredMetadata: Record<string, unknown> | undefined;
|
|
731
1084
|
|
|
732
1085
|
constructor(
|
|
733
1086
|
provider: ThreadProvider,
|
|
734
1087
|
id: string,
|
|
735
|
-
|
|
736
|
-
|
|
1088
|
+
restoreFn: RestoreFn,
|
|
1089
|
+
initialMetadata?: Record<string, unknown>
|
|
737
1090
|
) {
|
|
738
1091
|
this.provider = provider;
|
|
739
1092
|
this.id = id;
|
|
740
|
-
this
|
|
741
|
-
this.#
|
|
742
|
-
this.
|
|
1093
|
+
this.#restoreFn = restoreFn;
|
|
1094
|
+
this.#restoredMetadata = initialMetadata;
|
|
1095
|
+
this.state = new LazyThreadState(restoreFn);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
private async ensureMetadataLoaded(): Promise<void> {
|
|
1099
|
+
if (this.#metadata !== null) {
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// If we have initial metadata from thread creation, use it
|
|
1104
|
+
if (this.#restoredMetadata !== undefined) {
|
|
1105
|
+
this.#metadata = this.#restoredMetadata;
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (this.#metadataLoadPromise) {
|
|
1110
|
+
await this.#metadataLoadPromise;
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
this.#metadataLoadPromise = (async () => {
|
|
1115
|
+
try {
|
|
1116
|
+
await this.doLoadMetadata();
|
|
1117
|
+
} finally {
|
|
1118
|
+
this.#metadataLoadPromise = null;
|
|
1119
|
+
}
|
|
1120
|
+
})();
|
|
1121
|
+
|
|
1122
|
+
await this.#metadataLoadPromise;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
private async doLoadMetadata(): Promise<void> {
|
|
1126
|
+
const { metadata } = await this.#restoreFn();
|
|
1127
|
+
this.#metadata = metadata;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async getMetadata(): Promise<Record<string, unknown>> {
|
|
1131
|
+
await this.ensureMetadataLoaded();
|
|
1132
|
+
return { ...this.#metadata! };
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async setMetadata(metadata: Record<string, unknown>): Promise<void> {
|
|
1136
|
+
this.#metadata = metadata;
|
|
1137
|
+
this.#metadataDirty = true;
|
|
743
1138
|
}
|
|
744
1139
|
|
|
745
1140
|
addEventListener(eventName: ThreadEventName, callback: ThreadEventCallback<any>): void {
|
|
@@ -773,33 +1168,80 @@ export class DefaultThread implements Thread {
|
|
|
773
1168
|
}
|
|
774
1169
|
|
|
775
1170
|
/**
|
|
776
|
-
* Check if thread
|
|
1171
|
+
* Check if thread has any data (state or metadata)
|
|
1172
|
+
*/
|
|
1173
|
+
async empty(): Promise<boolean> {
|
|
1174
|
+
const stateSize = await this.state.size();
|
|
1175
|
+
// Check both loaded metadata and initial metadata from constructor
|
|
1176
|
+
const meta = this.#metadata ?? this.#restoredMetadata ?? {};
|
|
1177
|
+
return stateSize === 0 && Object.keys(meta).length === 0;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Check if thread needs saving
|
|
777
1182
|
* @internal
|
|
778
1183
|
*/
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1184
|
+
needsSave(): boolean {
|
|
1185
|
+
return this.state.dirty || this.#metadataDirty;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Get the save mode for this thread
|
|
1190
|
+
* @internal
|
|
1191
|
+
*/
|
|
1192
|
+
getSaveMode(): 'none' | 'merge' | 'full' {
|
|
1193
|
+
const stateStatus = this.state.getStatus();
|
|
1194
|
+
|
|
1195
|
+
if (stateStatus === 'idle' && !this.#metadataDirty) {
|
|
1196
|
+
return 'none';
|
|
782
1197
|
}
|
|
783
1198
|
|
|
784
|
-
|
|
1199
|
+
if (stateStatus === 'pending-writes') {
|
|
1200
|
+
return 'merge';
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
if (stateStatus === 'loaded' && (this.state.dirty || this.#metadataDirty)) {
|
|
1204
|
+
return 'full';
|
|
1205
|
+
}
|
|
785
1206
|
|
|
786
|
-
|
|
1207
|
+
// Only metadata was changed without loading state
|
|
1208
|
+
if (this.#metadataDirty) {
|
|
1209
|
+
return 'merge';
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
return 'none';
|
|
787
1213
|
}
|
|
788
1214
|
|
|
789
1215
|
/**
|
|
790
|
-
*
|
|
1216
|
+
* Get pending operations for merge command
|
|
1217
|
+
* @internal
|
|
791
1218
|
*/
|
|
792
|
-
|
|
793
|
-
return this.state.
|
|
1219
|
+
getPendingOperations(): MergeOperation[] {
|
|
1220
|
+
return this.state.getPendingOperations();
|
|
794
1221
|
}
|
|
795
1222
|
|
|
796
1223
|
/**
|
|
797
|
-
* Get
|
|
1224
|
+
* Get metadata for saving (returns null if not loaded/modified)
|
|
798
1225
|
* @internal
|
|
799
1226
|
*/
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1227
|
+
getMetadataForSave(): Record<string, unknown> | undefined {
|
|
1228
|
+
if (this.#metadataDirty && this.#metadata) {
|
|
1229
|
+
return this.#metadata;
|
|
1230
|
+
}
|
|
1231
|
+
return undefined;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* Get serialized state for full save.
|
|
1236
|
+
* Ensures state is loaded before serializing.
|
|
1237
|
+
* @internal
|
|
1238
|
+
*/
|
|
1239
|
+
async getSerializedState(): Promise<string> {
|
|
1240
|
+
const state = await this.state.getSerializedState();
|
|
1241
|
+
// Also ensure metadata is loaded
|
|
1242
|
+
const meta = this.#metadata ?? this.#restoredMetadata ?? {};
|
|
1243
|
+
const hasState = Object.keys(state).length > 0;
|
|
1244
|
+
const hasMetadata = Object.keys(meta).length > 0;
|
|
803
1245
|
|
|
804
1246
|
if (!hasState && !hasMetadata) {
|
|
805
1247
|
return '';
|
|
@@ -808,11 +1250,11 @@ export class DefaultThread implements Thread {
|
|
|
808
1250
|
const data: { state?: Record<string, unknown>; metadata?: Record<string, unknown> } = {};
|
|
809
1251
|
|
|
810
1252
|
if (hasState) {
|
|
811
|
-
data.state =
|
|
1253
|
+
data.state = state;
|
|
812
1254
|
}
|
|
813
1255
|
|
|
814
1256
|
if (hasMetadata) {
|
|
815
|
-
data.metadata =
|
|
1257
|
+
data.metadata = meta;
|
|
816
1258
|
}
|
|
817
1259
|
|
|
818
1260
|
return JSON.stringify(data);
|
|
@@ -1224,6 +1666,58 @@ export class ThreadWebSocketClient {
|
|
|
1224
1666
|
});
|
|
1225
1667
|
}
|
|
1226
1668
|
|
|
1669
|
+
async merge(
|
|
1670
|
+
threadId: string,
|
|
1671
|
+
operations: MergeOperation[],
|
|
1672
|
+
metadata?: Record<string, unknown>
|
|
1673
|
+
): Promise<void> {
|
|
1674
|
+
// Wait for connection/reconnection if in progress
|
|
1675
|
+
if (this.wsConnecting) {
|
|
1676
|
+
await this.wsConnecting;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
if (!this.authenticated || !this.ws) {
|
|
1680
|
+
throw new Error('WebSocket not connected or authenticated');
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
return new Promise((resolve, reject) => {
|
|
1684
|
+
const requestId = crypto.randomUUID();
|
|
1685
|
+
this.pendingRequests.set(requestId, {
|
|
1686
|
+
resolve: () => resolve(),
|
|
1687
|
+
reject,
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
const data: {
|
|
1691
|
+
thread_id: string;
|
|
1692
|
+
operations: MergeOperation[];
|
|
1693
|
+
metadata?: Record<string, unknown>;
|
|
1694
|
+
} = {
|
|
1695
|
+
thread_id: threadId,
|
|
1696
|
+
operations,
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
if (metadata && Object.keys(metadata).length > 0) {
|
|
1700
|
+
data.metadata = metadata;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const message = {
|
|
1704
|
+
id: requestId,
|
|
1705
|
+
action: 'merge',
|
|
1706
|
+
data,
|
|
1707
|
+
};
|
|
1708
|
+
|
|
1709
|
+
this.ws!.send(JSON.stringify(message));
|
|
1710
|
+
|
|
1711
|
+
// Timeout after configured duration
|
|
1712
|
+
setTimeout(() => {
|
|
1713
|
+
if (this.pendingRequests.has(requestId)) {
|
|
1714
|
+
this.pendingRequests.delete(requestId);
|
|
1715
|
+
reject(new Error('Request timeout'));
|
|
1716
|
+
}
|
|
1717
|
+
}, this.requestTimeoutMs);
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1227
1721
|
cleanup(): void {
|
|
1228
1722
|
// Mark as disposed to prevent new reconnection attempts
|
|
1229
1723
|
this.isDisposed = true;
|
|
@@ -1287,90 +1781,105 @@ export class DefaultThreadProvider implements ThreadProvider {
|
|
|
1287
1781
|
async restore(ctx: Context<Env>): Promise<Thread> {
|
|
1288
1782
|
const threadId = await this.threadIDProvider!.getThreadId(this.appState!, ctx);
|
|
1289
1783
|
validateThreadIdOrThrow(threadId);
|
|
1290
|
-
internal.info('[thread]
|
|
1784
|
+
internal.info('[thread] creating lazy thread %s (no eager restore)', threadId);
|
|
1291
1785
|
|
|
1292
|
-
//
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
}
|
|
1786
|
+
// Create a restore function that will be called lazily when state/metadata is accessed
|
|
1787
|
+
const restoreFn = async (): Promise<{
|
|
1788
|
+
state: Map<string, unknown>;
|
|
1789
|
+
metadata: Record<string, unknown>;
|
|
1790
|
+
}> => {
|
|
1791
|
+
internal.info('[thread] lazy loading state for thread %s', threadId);
|
|
1792
|
+
|
|
1793
|
+
// Wait for WebSocket connection if still connecting
|
|
1794
|
+
if (this.wsConnecting) {
|
|
1795
|
+
internal.info('[thread] waiting for WebSocket connection');
|
|
1796
|
+
await this.wsConnecting;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
if (!this.wsClient) {
|
|
1800
|
+
internal.info('[thread] no WebSocket client available, returning empty state');
|
|
1801
|
+
return { state: new Map(), metadata: {} };
|
|
1802
|
+
}
|
|
1297
1803
|
|
|
1298
|
-
// Restore thread state and metadata from WebSocket if available
|
|
1299
|
-
let initialStateJson: string | undefined;
|
|
1300
|
-
let restoredMetadata: Record<string, unknown> | undefined;
|
|
1301
|
-
if (this.wsClient) {
|
|
1302
1804
|
try {
|
|
1303
|
-
internal.info('[thread] restoring state from WebSocket');
|
|
1304
1805
|
const restoredData = await this.wsClient.restore(threadId);
|
|
1305
1806
|
if (restoredData) {
|
|
1306
1807
|
internal.info('[thread] restored state: %d bytes', restoredData.length);
|
|
1307
1808
|
const { flatStateJson, metadata } = parseThreadData(restoredData);
|
|
1308
|
-
initialStateJson = flatStateJson;
|
|
1309
|
-
restoredMetadata = metadata;
|
|
1310
|
-
} else {
|
|
1311
|
-
internal.info('[thread] no existing state found');
|
|
1312
|
-
}
|
|
1313
|
-
} catch (err) {
|
|
1314
|
-
internal.info('[thread] WebSocket restore failed: %s', err);
|
|
1315
|
-
// Continue with empty state rather than failing
|
|
1316
|
-
}
|
|
1317
|
-
} else {
|
|
1318
|
-
internal.info('[thread] no WebSocket client available');
|
|
1319
|
-
}
|
|
1320
1809
|
|
|
1321
|
-
|
|
1810
|
+
const state = new Map<string, unknown>();
|
|
1811
|
+
if (flatStateJson) {
|
|
1812
|
+
try {
|
|
1813
|
+
const data = JSON.parse(flatStateJson);
|
|
1814
|
+
for (const [key, value] of Object.entries(data)) {
|
|
1815
|
+
state.set(key, value);
|
|
1816
|
+
}
|
|
1817
|
+
} catch {
|
|
1818
|
+
internal.info('[thread] failed to parse state JSON');
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1322
1821
|
|
|
1323
|
-
|
|
1324
|
-
if (initialStateJson) {
|
|
1325
|
-
try {
|
|
1326
|
-
const data = JSON.parse(initialStateJson);
|
|
1327
|
-
for (const [key, value] of Object.entries(data)) {
|
|
1328
|
-
thread.state.set(key, value);
|
|
1822
|
+
return { state, metadata: metadata || {} };
|
|
1329
1823
|
}
|
|
1330
|
-
internal.info('[thread]
|
|
1824
|
+
internal.info('[thread] no existing state found');
|
|
1825
|
+
return { state: new Map(), metadata: {} };
|
|
1331
1826
|
} catch (err) {
|
|
1332
|
-
internal.info('[thread]
|
|
1333
|
-
|
|
1827
|
+
internal.info('[thread] WebSocket restore failed: %s', err);
|
|
1828
|
+
return { state: new Map(), metadata: {} };
|
|
1334
1829
|
}
|
|
1335
|
-
}
|
|
1830
|
+
};
|
|
1336
1831
|
|
|
1832
|
+
const thread = new DefaultThread(this, threadId, restoreFn);
|
|
1337
1833
|
await fireEvent('thread.created', thread);
|
|
1338
1834
|
return thread;
|
|
1339
1835
|
}
|
|
1340
1836
|
|
|
1341
1837
|
async save(thread: Thread): Promise<void> {
|
|
1342
1838
|
if (thread instanceof DefaultThread) {
|
|
1839
|
+
const saveMode = thread.getSaveMode();
|
|
1343
1840
|
internal.info(
|
|
1344
|
-
'[thread] DefaultThreadProvider.save() - thread %s,
|
|
1841
|
+
'[thread] DefaultThreadProvider.save() - thread %s, saveMode: %s, hasWsClient: %s',
|
|
1345
1842
|
thread.id,
|
|
1346
|
-
|
|
1843
|
+
saveMode,
|
|
1347
1844
|
!!this.wsClient
|
|
1348
1845
|
);
|
|
1349
1846
|
|
|
1847
|
+
if (saveMode === 'none') {
|
|
1848
|
+
internal.info('[thread] skipping save - no changes');
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1350
1852
|
// Wait for WebSocket connection if still connecting
|
|
1351
1853
|
if (this.wsConnecting) {
|
|
1352
1854
|
internal.info('[thread] waiting for WebSocket connection');
|
|
1353
1855
|
await this.wsConnecting;
|
|
1354
1856
|
}
|
|
1355
1857
|
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1858
|
+
if (!this.wsClient) {
|
|
1859
|
+
internal.info('[thread] no WebSocket client available, skipping save');
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
try {
|
|
1864
|
+
if (saveMode === 'merge') {
|
|
1865
|
+
const operations = thread.getPendingOperations();
|
|
1866
|
+
const metadata = thread.getMetadataForSave();
|
|
1360
1867
|
internal.info(
|
|
1361
|
-
'[thread]
|
|
1362
|
-
|
|
1868
|
+
'[thread] sending merge command with %d operations',
|
|
1869
|
+
operations.length
|
|
1363
1870
|
);
|
|
1364
|
-
|
|
1365
|
-
|
|
1871
|
+
await this.wsClient.merge(thread.id, operations, metadata);
|
|
1872
|
+
internal.info('[thread] WebSocket merge completed');
|
|
1873
|
+
} else if (saveMode === 'full') {
|
|
1874
|
+
const serialized = await thread.getSerializedState();
|
|
1875
|
+
internal.info('[thread] saving to WebSocket, serialized length: %d', serialized.length);
|
|
1876
|
+
const metadata = thread.getMetadataForSave();
|
|
1366
1877
|
await this.wsClient.save(thread.id, serialized, metadata);
|
|
1367
1878
|
internal.info('[thread] WebSocket save completed');
|
|
1368
|
-
} catch (err) {
|
|
1369
|
-
internal.info('[thread] WebSocket save failed: %s', err);
|
|
1370
|
-
// Don't throw - allow request to complete even if save fails
|
|
1371
1879
|
}
|
|
1372
|
-
}
|
|
1373
|
-
internal.info('[thread]
|
|
1880
|
+
} catch (err) {
|
|
1881
|
+
internal.info('[thread] WebSocket save/merge failed: %s', err);
|
|
1882
|
+
// Don't throw - allow request to complete even if save fails
|
|
1374
1883
|
}
|
|
1375
1884
|
}
|
|
1376
1885
|
}
|