zeitlich 0.2.45 → 0.2.46
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 +78 -10
- package/dist/{activities-CrN-ghLo.d.ts → activities-Bm4TLTid.d.ts} +22 -2
- package/dist/{activities-Coafq5zr.d.cts → activities-CyeiqK_f.d.cts} +22 -2
- package/dist/adapters/thread/anthropic/index.cjs +171 -65
- package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/index.d.cts +19 -4
- package/dist/adapters/thread/anthropic/index.d.ts +19 -4
- package/dist/adapters/thread/anthropic/index.js +171 -65
- package/dist/adapters/thread/anthropic/index.js.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.cjs +3 -1
- package/dist/adapters/thread/anthropic/workflow.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.d.cts +4 -4
- package/dist/adapters/thread/anthropic/workflow.d.ts +4 -4
- package/dist/adapters/thread/anthropic/workflow.js +3 -1
- package/dist/adapters/thread/anthropic/workflow.js.map +1 -1
- package/dist/adapters/thread/google-genai/index.cjs +171 -69
- package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/index.d.cts +5 -4
- package/dist/adapters/thread/google-genai/index.d.ts +5 -4
- package/dist/adapters/thread/google-genai/index.js +171 -69
- package/dist/adapters/thread/google-genai/index.js.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.cjs +3 -1
- package/dist/adapters/thread/google-genai/workflow.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.d.cts +5 -4
- package/dist/adapters/thread/google-genai/workflow.d.ts +5 -4
- package/dist/adapters/thread/google-genai/workflow.js +3 -1
- package/dist/adapters/thread/google-genai/workflow.js.map +1 -1
- package/dist/adapters/thread/langchain/index.cjs +170 -66
- package/dist/adapters/thread/langchain/index.cjs.map +1 -1
- package/dist/adapters/thread/langchain/index.d.cts +18 -4
- package/dist/adapters/thread/langchain/index.d.ts +18 -4
- package/dist/adapters/thread/langchain/index.js +170 -66
- package/dist/adapters/thread/langchain/index.js.map +1 -1
- package/dist/adapters/thread/langchain/workflow.cjs +3 -1
- package/dist/adapters/thread/langchain/workflow.cjs.map +1 -1
- package/dist/adapters/thread/langchain/workflow.d.cts +4 -4
- package/dist/adapters/thread/langchain/workflow.d.ts +4 -4
- package/dist/adapters/thread/langchain/workflow.js +3 -1
- package/dist/adapters/thread/langchain/workflow.js.map +1 -1
- package/dist/cold-store-BC5L5Z8A.d.cts +117 -0
- package/dist/cold-store-CFHwemBJ.d.ts +117 -0
- package/dist/index.cjs +226 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +138 -8
- package/dist/index.d.ts +138 -8
- package/dist/index.js +220 -28
- package/dist/index.js.map +1 -1
- package/dist/{proxy-Bf7uI-Hw.d.cts → proxy-BxFyd6cg.d.cts} +1 -1
- package/dist/{proxy-COqA95FW.d.ts → proxy-Cskmj4Yx.d.ts} +1 -1
- package/dist/{thread-manager-BsLO3Fgc.d.cts → thread-manager-9tezUcLW.d.cts} +8 -2
- package/dist/{thread-manager-Bi1XlbpJ.d.ts → thread-manager-B-zy3xrs.d.ts} +8 -2
- package/dist/{thread-manager-wRVVBFgj.d.cts → thread-manager-D33SUmZa.d.cts} +8 -2
- package/dist/{thread-manager-BhkOyQ1I.d.ts → thread-manager-DduoSkvJ.d.ts} +8 -2
- package/dist/{types-CdALEF3z.d.cts → types-CnuN9T6t.d.cts} +22 -0
- package/dist/{types-ChAy_jSP.d.ts → types-CwN6_tAL.d.ts} +22 -0
- package/dist/{types-BkX4HLzi.d.ts → types-L5bvbF-n.d.ts} +17 -1
- package/dist/{types-C66-BVBr.d.cts → types-oxt8GN97.d.cts} +17 -1
- package/dist/{workflow-BwT5EybR.d.ts → workflow-B1TOcHbt.d.ts} +33 -2
- package/dist/{workflow-DMmiaw6w.d.cts → workflow-DIaIV7L2.d.cts} +33 -2
- package/dist/workflow.cjs +14 -1
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.d.cts +2 -2
- package/dist/workflow.d.ts +2 -2
- package/dist/workflow.js +14 -1
- package/dist/workflow.js.map +1 -1
- package/package.json +6 -1
- package/src/adapters/thread/anthropic/activities.ts +72 -36
- package/src/adapters/thread/anthropic/thread-manager.ts +9 -1
- package/src/adapters/thread/google-genai/activities.ts +64 -40
- package/src/adapters/thread/google-genai/thread-manager.ts +9 -1
- package/src/adapters/thread/langchain/activities.ts +63 -36
- package/src/adapters/thread/langchain/thread-manager.ts +9 -1
- package/src/index.ts +20 -1
- package/src/lib/session/session-edge-cases.integration.test.ts +12 -0
- package/src/lib/session/session.integration.test.ts +138 -0
- package/src/lib/session/session.ts +29 -0
- package/src/lib/session/types.ts +22 -0
- package/src/lib/thread/cold-store.test.ts +193 -0
- package/src/lib/thread/cold-store.ts +250 -0
- package/src/lib/thread/index.ts +32 -0
- package/src/lib/thread/keys.ts +20 -0
- package/src/lib/thread/manager.ts +16 -27
- package/src/lib/thread/proxy.ts +2 -0
- package/src/lib/thread/snapshot.test.ts +443 -0
- package/src/lib/thread/snapshot.ts +163 -0
- package/src/lib/thread/test-utils.ts +228 -0
- package/src/lib/thread/tiered.test.ts +281 -0
- package/src/lib/thread/tiered.ts +135 -0
- package/src/lib/thread/types.ts +16 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable cold-tier interface for thread archives.
|
|
3
|
+
*
|
|
4
|
+
* Zeitlich's thread manager is a Redis-backed hot tier optimized for
|
|
5
|
+
* the duration of a workflow run. A `ColdThreadStore` provides the
|
|
6
|
+
* durable archive: each thread is serialized to a single
|
|
7
|
+
* {@link ThreadSnapshot} blob (messages + persisted state slice +
|
|
8
|
+
* dedup-id ledger) at session-exit time, and restored at session-entry
|
|
9
|
+
* time when the workflow is resumed or forked.
|
|
10
|
+
*
|
|
11
|
+
* The contract is intentionally minimal — one read, one write, one
|
|
12
|
+
* delete keyed by `(threadKey, threadId)`. Any storage backend that
|
|
13
|
+
* can satisfy these three calls (S3, R2, GCS, Postgres, the local
|
|
14
|
+
* filesystem, etc.) can plug into the same tiered manager.
|
|
15
|
+
*
|
|
16
|
+
* Concurrency assumption: zeitlich assumes a single active session
|
|
17
|
+
* per `(threadKey, threadId)` at a time, so cold writes are
|
|
18
|
+
* last-writer-wins; no compare-and-swap is required.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { gzipSync, gunzipSync } from "node:zlib";
|
|
22
|
+
import type { PersistedThreadState } from "../state/types";
|
|
23
|
+
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Serialized form of a thread that can be written to and read from a
|
|
27
|
+
* {@link ColdThreadStore}.
|
|
28
|
+
*
|
|
29
|
+
* `messages` is the list of raw-serialized message strings exactly as
|
|
30
|
+
* they were stored in the Redis list — keeping the cold tier opaque
|
|
31
|
+
* to the adapter-specific message envelope. `state` is the
|
|
32
|
+
* {@link PersistedThreadState} that the session writes via
|
|
33
|
+
* `saveThreadState` on every exit path, or `null` if none has been
|
|
34
|
+
* written yet. `dedupIds` lets the tiered manager re-prime the
|
|
35
|
+
* idempotent-append dedup markers when restoring, so a rewind retry
|
|
36
|
+
* after a continue cannot accidentally re-append a message.
|
|
37
|
+
*/
|
|
38
|
+
export interface ThreadSnapshot {
|
|
39
|
+
v: 1;
|
|
40
|
+
messages: string[];
|
|
41
|
+
state: PersistedThreadState | null;
|
|
42
|
+
dedupIds: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Pluggable cold archive for thread snapshots. */
|
|
46
|
+
export interface ColdThreadStore {
|
|
47
|
+
/**
|
|
48
|
+
* Read the latest snapshot for `(threadKey, threadId)`, or return
|
|
49
|
+
* `null` if no snapshot has ever been written.
|
|
50
|
+
*/
|
|
51
|
+
read(
|
|
52
|
+
threadKey: string,
|
|
53
|
+
threadId: string
|
|
54
|
+
): Promise<ThreadSnapshot | null>;
|
|
55
|
+
/**
|
|
56
|
+
* Persist `snapshot` as the latest archive for `(threadKey,
|
|
57
|
+
* threadId)`. Overwrites any prior snapshot in place.
|
|
58
|
+
*/
|
|
59
|
+
write(
|
|
60
|
+
threadKey: string,
|
|
61
|
+
threadId: string,
|
|
62
|
+
snapshot: ThreadSnapshot
|
|
63
|
+
): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Permanently remove the archive for `(threadKey, threadId)`.
|
|
66
|
+
* No-op if no snapshot exists.
|
|
67
|
+
*/
|
|
68
|
+
delete(threadKey: string, threadId: string): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compact, duck-typed shape of an S3 client. Zeitlich only needs the
|
|
73
|
+
* `send(...)` method; declaring this locally avoids forcing
|
|
74
|
+
* `@aws-sdk/client-s3` to be installed when the consumer is using a
|
|
75
|
+
* different cold-store backend.
|
|
76
|
+
*/
|
|
77
|
+
export interface S3LikeClient {
|
|
78
|
+
send<TInput, TOutput>(
|
|
79
|
+
command: { input: TInput } & object
|
|
80
|
+
): Promise<TOutput>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Configuration for the built-in S3 cold store. */
|
|
84
|
+
export interface S3ColdStoreConfig {
|
|
85
|
+
/** An `@aws-sdk/client-s3` `S3Client` (or duck-typed equivalent). */
|
|
86
|
+
s3: S3LikeClient;
|
|
87
|
+
/** S3 bucket that holds the archive. */
|
|
88
|
+
bucket: string;
|
|
89
|
+
/**
|
|
90
|
+
* Optional key prefix applied to every object. The final key layout
|
|
91
|
+
* is `${prefix}/${threadKey}/${threadId}.json[.gz]` with leading
|
|
92
|
+
* slashes stripped. Defaults to `"threads"`.
|
|
93
|
+
*/
|
|
94
|
+
prefix?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Gzip the JSON payload before uploading and assume gzip on read.
|
|
97
|
+
* Defaults to `true` — message lists are highly compressible.
|
|
98
|
+
*/
|
|
99
|
+
gzip?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Optional `Content-Type` override. Defaults to
|
|
102
|
+
* `application/json` (or `application/gzip` when `gzip: true`).
|
|
103
|
+
*/
|
|
104
|
+
contentType?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function joinKey(parts: string[]): string {
|
|
108
|
+
return parts
|
|
109
|
+
.map((p) => p.replace(/^\/+|\/+$/g, ""))
|
|
110
|
+
.filter((p) => p.length > 0)
|
|
111
|
+
.join("/");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildKey(
|
|
115
|
+
prefix: string | undefined,
|
|
116
|
+
threadKey: string,
|
|
117
|
+
threadId: string,
|
|
118
|
+
gzip: boolean
|
|
119
|
+
): string {
|
|
120
|
+
const ext = gzip ? "json.gz" : "json";
|
|
121
|
+
return joinKey([
|
|
122
|
+
prefix ?? "threads",
|
|
123
|
+
threadKey,
|
|
124
|
+
`${threadId}.${ext}`,
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function streamToBuffer(
|
|
129
|
+
body: unknown
|
|
130
|
+
): Promise<Buffer> {
|
|
131
|
+
if (body == null) return Buffer.alloc(0);
|
|
132
|
+
if (body instanceof Uint8Array) return Buffer.from(body);
|
|
133
|
+
if (typeof (body as { transformToByteArray?: () => Promise<Uint8Array> })
|
|
134
|
+
.transformToByteArray === "function") {
|
|
135
|
+
const bytes = await (
|
|
136
|
+
body as { transformToByteArray: () => Promise<Uint8Array> }
|
|
137
|
+
).transformToByteArray();
|
|
138
|
+
return Buffer.from(bytes);
|
|
139
|
+
}
|
|
140
|
+
if (typeof (body as { arrayBuffer?: () => Promise<ArrayBuffer> })
|
|
141
|
+
.arrayBuffer === "function") {
|
|
142
|
+
const ab = await (
|
|
143
|
+
body as { arrayBuffer: () => Promise<ArrayBuffer> }
|
|
144
|
+
).arrayBuffer();
|
|
145
|
+
return Buffer.from(ab);
|
|
146
|
+
}
|
|
147
|
+
// Node.js Readable stream fallback
|
|
148
|
+
const chunks: Buffer[] = [];
|
|
149
|
+
for await (const chunk of body as AsyncIterable<Buffer | Uint8Array>) {
|
|
150
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
151
|
+
}
|
|
152
|
+
return Buffer.concat(chunks);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Build an S3-backed {@link ColdThreadStore}.
|
|
157
|
+
*
|
|
158
|
+
* One object per thread at
|
|
159
|
+
* `${prefix}/${threadKey}/${threadId}.json[.gz]`, JSON-encoded and
|
|
160
|
+
* gzip-compressed by default. The consumer owns the `S3Client`
|
|
161
|
+
* instance, so credentials, region, and endpoint configuration live
|
|
162
|
+
* outside zeitlich.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```typescript
|
|
166
|
+
* import { S3Client } from "@aws-sdk/client-s3";
|
|
167
|
+
* import { createS3ColdStore } from "zeitlich";
|
|
168
|
+
*
|
|
169
|
+
* const coldStore = createS3ColdStore({
|
|
170
|
+
* s3: new S3Client({ region: "us-east-1" }),
|
|
171
|
+
* bucket: "my-threads",
|
|
172
|
+
* prefix: "prod/threads",
|
|
173
|
+
* });
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
export function createS3ColdStore(
|
|
177
|
+
config: S3ColdStoreConfig
|
|
178
|
+
): ColdThreadStore {
|
|
179
|
+
const { s3, bucket, prefix, gzip = true } = config;
|
|
180
|
+
const contentType =
|
|
181
|
+
config.contentType ?? (gzip ? "application/gzip" : "application/json");
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
async read(
|
|
185
|
+
threadKey: string,
|
|
186
|
+
threadId: string
|
|
187
|
+
): Promise<ThreadSnapshot | null> {
|
|
188
|
+
const Key = buildKey(prefix, threadKey, threadId, gzip);
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const resp = (await s3.send(
|
|
192
|
+
new GetObjectCommand({ Bucket: bucket, Key })
|
|
193
|
+
)) as { Body?: unknown };
|
|
194
|
+
const buf = await streamToBuffer(resp.Body);
|
|
195
|
+
const json = gzip
|
|
196
|
+
? gunzipSync(buf).toString("utf8")
|
|
197
|
+
: buf.toString("utf8");
|
|
198
|
+
return JSON.parse(json) as ThreadSnapshot;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (isNotFound(err)) return null;
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
async write(
|
|
206
|
+
threadKey: string,
|
|
207
|
+
threadId: string,
|
|
208
|
+
snapshot: ThreadSnapshot
|
|
209
|
+
): Promise<void> {
|
|
210
|
+
const Key = buildKey(prefix, threadKey, threadId, gzip);
|
|
211
|
+
const json = JSON.stringify(snapshot);
|
|
212
|
+
const body = gzip ? gzipSync(Buffer.from(json, "utf8")) : json;
|
|
213
|
+
|
|
214
|
+
await s3.send(
|
|
215
|
+
new PutObjectCommand({
|
|
216
|
+
Bucket: bucket,
|
|
217
|
+
Key,
|
|
218
|
+
Body: body,
|
|
219
|
+
ContentType: contentType,
|
|
220
|
+
})
|
|
221
|
+
);
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
async delete(
|
|
225
|
+
threadKey: string,
|
|
226
|
+
threadId: string
|
|
227
|
+
): Promise<void> {
|
|
228
|
+
const Key = buildKey(prefix, threadKey, threadId, gzip);
|
|
229
|
+
|
|
230
|
+
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key }));
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isNotFound(err: unknown): boolean {
|
|
236
|
+
if (typeof err !== "object" || err === null) return false;
|
|
237
|
+
const e = err as {
|
|
238
|
+
name?: string;
|
|
239
|
+
Code?: string;
|
|
240
|
+
code?: string;
|
|
241
|
+
$metadata?: { httpStatusCode?: number };
|
|
242
|
+
};
|
|
243
|
+
return (
|
|
244
|
+
e.name === "NoSuchKey" ||
|
|
245
|
+
e.Code === "NoSuchKey" ||
|
|
246
|
+
e.code === "NoSuchKey" ||
|
|
247
|
+
e.name === "NotFound" ||
|
|
248
|
+
e.$metadata?.httpStatusCode === 404
|
|
249
|
+
);
|
|
250
|
+
}
|
package/src/lib/thread/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ export {
|
|
|
4
4
|
THREAD_TTL_SECONDS,
|
|
5
5
|
getThreadListKey,
|
|
6
6
|
getThreadMetaKey,
|
|
7
|
+
getThreadStateKey,
|
|
8
|
+
getThreadDedupKey,
|
|
7
9
|
} from "./keys";
|
|
8
10
|
|
|
9
11
|
export type {
|
|
@@ -12,3 +14,33 @@ export type {
|
|
|
12
14
|
ProviderThreadManager,
|
|
13
15
|
ThreadManagerHooks,
|
|
14
16
|
} from "./types";
|
|
17
|
+
|
|
18
|
+
// Cold-tier (S3-style) thread archive
|
|
19
|
+
export { createS3ColdStore } from "./cold-store";
|
|
20
|
+
export type {
|
|
21
|
+
ColdThreadStore,
|
|
22
|
+
ThreadSnapshot,
|
|
23
|
+
S3LikeClient,
|
|
24
|
+
S3ColdStoreConfig,
|
|
25
|
+
} from "./cold-store";
|
|
26
|
+
|
|
27
|
+
// Tiered (Redis hot + pluggable cold) thread manager
|
|
28
|
+
export { createTieredThreadManager } from "./tiered";
|
|
29
|
+
export type {
|
|
30
|
+
TieredThreadManager,
|
|
31
|
+
TieredThreadManagerConfig,
|
|
32
|
+
FlushOptions,
|
|
33
|
+
} from "./tiered";
|
|
34
|
+
|
|
35
|
+
// Low-level snapshot helpers (advanced — for custom cold stores or
|
|
36
|
+
// admin tooling that mirrors zeitlich's hot↔cold transitions).
|
|
37
|
+
export {
|
|
38
|
+
encodeSnapshot,
|
|
39
|
+
applySnapshot,
|
|
40
|
+
clearHotTier,
|
|
41
|
+
} from "./snapshot";
|
|
42
|
+
export type {
|
|
43
|
+
EncodeSnapshotConfig,
|
|
44
|
+
ApplySnapshotConfig,
|
|
45
|
+
ClearHotTierConfig,
|
|
46
|
+
} from "./snapshot";
|
package/src/lib/thread/keys.ts
CHANGED
|
@@ -92,3 +92,23 @@ export function getThreadStateKey(
|
|
|
92
92
|
): string {
|
|
93
93
|
return `${threadKey}:state:thread:${threadId}`;
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the Redis key that guards an idempotent append against a
|
|
98
|
+
* duplicate write of the message (or message batch) identified by
|
|
99
|
+
* `dedupId`. Zeitlich's thread manager writes one of these per
|
|
100
|
+
* single-message append (and one per batch for multi-message appends),
|
|
101
|
+
* keyed by the message id returned by the configured `idOf`.
|
|
102
|
+
*
|
|
103
|
+
* Note: the key layout intentionally does **not** include the
|
|
104
|
+
* `threadKey` prefix — the dedup namespace is shared across thread
|
|
105
|
+
* keys for a given `threadId`, mirroring the original internal
|
|
106
|
+
* implementation.
|
|
107
|
+
*
|
|
108
|
+
* @param threadId - Thread id as provided to the thread manager.
|
|
109
|
+
* @param dedupId - Joined message ids (single message id for the
|
|
110
|
+
* common single-append case).
|
|
111
|
+
*/
|
|
112
|
+
export function getThreadDedupKey(threadId: string, dedupId: string): string {
|
|
113
|
+
return `dedup:${dedupId}:thread:${threadId}`;
|
|
114
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
getThreadListKey,
|
|
6
6
|
getThreadMetaKey,
|
|
7
7
|
getThreadStateKey,
|
|
8
|
+
getThreadDedupKey,
|
|
8
9
|
} from "./keys";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -28,10 +29,6 @@ redis.call('SET', KEYS[1], '1', 'EX', tonumber(ARGV[1]))
|
|
|
28
29
|
return 1
|
|
29
30
|
`;
|
|
30
31
|
|
|
31
|
-
function getDedupKey(threadId: string, id: string): string {
|
|
32
|
-
return `dedup:${id}:thread:${threadId}`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
32
|
/**
|
|
36
33
|
* Creates a generic thread manager for handling conversation state in Redis.
|
|
37
34
|
* Framework-agnostic — works with any serializable message type.
|
|
@@ -46,10 +43,12 @@ export function createThreadManager<T>(
|
|
|
46
43
|
serialize = (m: T): string => JSON.stringify(m),
|
|
47
44
|
deserialize = (raw: string): T => JSON.parse(raw) as T,
|
|
48
45
|
idOf,
|
|
46
|
+
ttlSeconds = THREAD_TTL_SECONDS,
|
|
49
47
|
} = config;
|
|
50
48
|
const redisKey = getThreadListKey(key, threadId);
|
|
51
49
|
const metaKey = getThreadMetaKey(key, threadId);
|
|
52
50
|
const stateKey = getThreadStateKey(key, threadId);
|
|
51
|
+
const dedupKey = (id: string): string => getThreadDedupKey(threadId, id);
|
|
53
52
|
|
|
54
53
|
async function assertThreadExists(): Promise<void> {
|
|
55
54
|
const exists = await redis.exists(metaKey);
|
|
@@ -61,7 +60,7 @@ export function createThreadManager<T>(
|
|
|
61
60
|
return {
|
|
62
61
|
async initialize(): Promise<void> {
|
|
63
62
|
await redis.del(redisKey);
|
|
64
|
-
await redis.set(metaKey, "1", "EX",
|
|
63
|
+
await redis.set(metaKey, "1", "EX", ttlSeconds);
|
|
65
64
|
},
|
|
66
65
|
|
|
67
66
|
async load(): Promise<T[]> {
|
|
@@ -76,18 +75,17 @@ export function createThreadManager<T>(
|
|
|
76
75
|
|
|
77
76
|
if (idOf) {
|
|
78
77
|
const dedupId = messages.map(idOf).join(":");
|
|
79
|
-
const dedupKey = getDedupKey(threadId, dedupId);
|
|
80
78
|
await redis.eval(
|
|
81
79
|
APPEND_IDEMPOTENT_SCRIPT,
|
|
82
80
|
2,
|
|
83
|
-
dedupKey,
|
|
81
|
+
dedupKey(dedupId),
|
|
84
82
|
redisKey,
|
|
85
|
-
String(
|
|
83
|
+
String(ttlSeconds),
|
|
86
84
|
...messages.map(serialize)
|
|
87
85
|
);
|
|
88
86
|
} else {
|
|
89
87
|
await redis.rpush(redisKey, ...messages.map(serialize));
|
|
90
|
-
await redis.expire(redisKey,
|
|
88
|
+
await redis.expire(redisKey, ttlSeconds);
|
|
91
89
|
}
|
|
92
90
|
},
|
|
93
91
|
|
|
@@ -103,11 +101,11 @@ export function createThreadManager<T>(
|
|
|
103
101
|
if (data.length > 0) {
|
|
104
102
|
const newKey = getThreadListKey(key, newThreadId);
|
|
105
103
|
await redis.rpush(newKey, ...data);
|
|
106
|
-
await redis.expire(newKey,
|
|
104
|
+
await redis.expire(newKey, ttlSeconds);
|
|
107
105
|
}
|
|
108
106
|
if (stateRaw != null) {
|
|
109
107
|
const newStateKey = getThreadStateKey(key, newThreadId);
|
|
110
|
-
await redis.set(newStateKey, stateRaw, "EX",
|
|
108
|
+
await redis.set(newStateKey, stateRaw, "EX", ttlSeconds);
|
|
111
109
|
}
|
|
112
110
|
return forked;
|
|
113
111
|
},
|
|
@@ -125,15 +123,13 @@ export function createThreadManager<T>(
|
|
|
125
123
|
.filter((id): id is string => typeof id === "string");
|
|
126
124
|
await redis.del(redisKey);
|
|
127
125
|
if (existingIds.length > 0) {
|
|
128
|
-
await redis.del(
|
|
129
|
-
...existingIds.map((id) => getDedupKey(threadId, id))
|
|
130
|
-
);
|
|
126
|
+
await redis.del(...existingIds.map(dedupKey));
|
|
131
127
|
}
|
|
132
128
|
if (messages.length > 0) {
|
|
133
129
|
await redis.rpush(redisKey, ...messages.map(serialize));
|
|
134
|
-
await redis.expire(redisKey,
|
|
130
|
+
await redis.expire(redisKey, ttlSeconds);
|
|
135
131
|
}
|
|
136
|
-
await redis.expire(metaKey,
|
|
132
|
+
await redis.expire(metaKey, ttlSeconds);
|
|
137
133
|
},
|
|
138
134
|
|
|
139
135
|
async delete(): Promise<void> {
|
|
@@ -148,12 +144,7 @@ export function createThreadManager<T>(
|
|
|
148
144
|
|
|
149
145
|
async saveState(state: PersistedThreadState): Promise<void> {
|
|
150
146
|
await assertThreadExists();
|
|
151
|
-
await redis.set(
|
|
152
|
-
stateKey,
|
|
153
|
-
JSON.stringify(state),
|
|
154
|
-
"EX",
|
|
155
|
-
THREAD_TTL_SECONDS
|
|
156
|
-
);
|
|
147
|
+
await redis.set(stateKey, JSON.stringify(state), "EX", ttlSeconds);
|
|
157
148
|
},
|
|
158
149
|
|
|
159
150
|
async deleteState(): Promise<void> {
|
|
@@ -185,19 +176,17 @@ export function createThreadManager<T>(
|
|
|
185
176
|
if (idx === -1) return;
|
|
186
177
|
if (idx === 0) {
|
|
187
178
|
await redis.del(redisKey);
|
|
188
|
-
await redis.expire(metaKey,
|
|
179
|
+
await redis.expire(metaKey, ttlSeconds);
|
|
189
180
|
} else {
|
|
190
181
|
await redis.ltrim(redisKey, 0, idx - 1);
|
|
191
|
-
await redis.expire(redisKey,
|
|
182
|
+
await redis.expire(redisKey, ttlSeconds);
|
|
192
183
|
}
|
|
193
184
|
// Clear dedup markers for the removed messages so that a rewind
|
|
194
185
|
// retry which reuses the same ids (e.g. the same assistantId) can
|
|
195
186
|
// re-append without the idempotent-append Lua script treating it
|
|
196
187
|
// as a duplicate.
|
|
197
188
|
if (removedIds.length > 0) {
|
|
198
|
-
await redis.del(
|
|
199
|
-
...removedIds.map((id) => getDedupKey(threadId, id))
|
|
200
|
-
);
|
|
189
|
+
await redis.del(...removedIds.map(dedupKey));
|
|
201
190
|
}
|
|
202
191
|
},
|
|
203
192
|
};
|
package/src/lib/thread/proxy.ts
CHANGED
|
@@ -56,5 +56,7 @@ export function createThreadOpsProxy(
|
|
|
56
56
|
truncateThread: acts[p("truncateThread")],
|
|
57
57
|
loadThreadState: acts[p("loadThreadState")],
|
|
58
58
|
saveThreadState: acts[p("saveThreadState")],
|
|
59
|
+
hydrateThread: acts[p("hydrateThread")],
|
|
60
|
+
flushThread: acts[p("flushThread")],
|
|
59
61
|
} as ActivityInterfaceFor<ThreadOps>;
|
|
60
62
|
}
|