zeitlich 0.2.48 → 0.2.50
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 +26 -23
- package/dist/{activities-DCaIPQBT.d.ts → activities-IuOIvPHO.d.ts} +6 -6
- package/dist/{activities-BlQR5gX4.d.cts → activities-cIlq1y1y.d.cts} +6 -6
- package/dist/adapters/sandbox/daytona/index.cjs.map +1 -1
- package/dist/adapters/sandbox/daytona/index.d.cts +3 -3
- package/dist/adapters/sandbox/daytona/index.d.ts +3 -3
- package/dist/adapters/sandbox/daytona/index.js.map +1 -1
- package/dist/adapters/sandbox/daytona/workflow.d.cts +2 -2
- package/dist/adapters/sandbox/daytona/workflow.d.ts +2 -2
- package/dist/adapters/sandbox/e2b/index.cjs.map +1 -1
- package/dist/adapters/sandbox/e2b/index.d.cts +1 -1
- package/dist/adapters/sandbox/e2b/index.d.ts +1 -1
- package/dist/adapters/sandbox/e2b/index.js.map +1 -1
- package/dist/adapters/sandbox/e2b/workflow.d.cts +1 -1
- package/dist/adapters/sandbox/e2b/workflow.d.ts +1 -1
- package/dist/adapters/thread/anthropic/index.cjs +45 -42
- package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/index.d.cts +10 -10
- package/dist/adapters/thread/anthropic/index.d.ts +10 -10
- package/dist/adapters/thread/anthropic/index.js +45 -42
- package/dist/adapters/thread/anthropic/index.js.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.d.cts +7 -7
- package/dist/adapters/thread/anthropic/workflow.d.ts +7 -7
- package/dist/adapters/thread/google-genai/index.cjs +117 -54
- package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/index.d.cts +27 -23
- package/dist/adapters/thread/google-genai/index.d.ts +27 -23
- package/dist/adapters/thread/google-genai/index.js +117 -54
- package/dist/adapters/thread/google-genai/index.js.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.d.cts +8 -8
- package/dist/adapters/thread/google-genai/workflow.d.ts +8 -8
- package/dist/adapters/thread/langchain/index.cjs +45 -42
- package/dist/adapters/thread/langchain/index.cjs.map +1 -1
- package/dist/adapters/thread/langchain/index.d.cts +10 -10
- package/dist/adapters/thread/langchain/index.d.ts +10 -10
- package/dist/adapters/thread/langchain/index.js +45 -42
- package/dist/adapters/thread/langchain/index.js.map +1 -1
- package/dist/adapters/thread/langchain/workflow.d.cts +7 -7
- package/dist/adapters/thread/langchain/workflow.d.ts +7 -7
- package/dist/{cold-store-UL13Sstw.d.cts → cold-store-C0uvYTSi.d.cts} +1 -1
- package/dist/{cold-store-aD4TSKlU.d.ts → cold-store-CCnZYWjx.d.ts} +1 -1
- package/dist/index.cjs +15063 -405
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +79 -83
- package/dist/index.d.ts +79 -83
- package/dist/index.js +15064 -402
- package/dist/index.js.map +1 -1
- package/dist/{proxy-BAty3CWM.d.cts → proxy-BVznA2_p.d.cts} +1 -1
- package/dist/{proxy-mbnwBhHw.d.ts → proxy-C4J1pNUk.d.ts} +1 -1
- package/dist/{thread-manager-CICj68PI.d.ts → thread-manager-BqjzWsP7.d.ts} +4 -4
- package/dist/{thread-manager-R6c3lnJy.d.cts → thread-manager-CzIs47uG.d.cts} +4 -4
- package/dist/{thread-manager-DsXvJ5cJ.d.cts → thread-manager-Dzl1fHhV.d.cts} +4 -4
- package/dist/{thread-manager-DtEtbUkp.d.ts → thread-manager-SkSWRPRc.d.ts} +4 -4
- package/dist/{types-gVa5XCWD.d.ts → types-BQvXWcft.d.ts} +1 -1
- package/dist/{types-DF4wzWQG.d.ts → types-CbPnU4RM.d.ts} +3 -3
- package/dist/{types-CJ7tCdl6.d.cts → types-D8W5TnSa.d.cts} +3 -3
- package/dist/{types-CJ7tCdl6.d.ts → types-D8W5TnSa.d.ts} +3 -3
- package/dist/{types-DwBYd0ij.d.ts → types-DZnUqCAP.d.cts} +709 -686
- package/dist/{types-CjY93AWZ.d.cts → types-OEN1xrFg.d.cts} +1 -1
- package/dist/{types-DWeyCTYK.d.cts → types-YNesmGKV.d.ts} +709 -686
- package/dist/{types-DDLPnxBh.d.cts → types-d2RvEP6v.d.cts} +3 -3
- package/dist/{workflow-DdaU7_j4.d.ts → workflow-B3oTe2_D.d.cts} +34 -3
- package/dist/{workflow-DVNPR7eX.d.cts → workflow-Bkzg0cjB.d.ts} +34 -3
- package/dist/workflow.cjs +15021 -362
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.d.cts +3 -3
- package/dist/workflow.d.ts +3 -3
- package/dist/workflow.js +15022 -359
- package/dist/workflow.js.map +1 -1
- package/package.json +10 -37
- package/src/adapters/thread/anthropic/activities.ts +1 -1
- package/src/adapters/thread/anthropic/fork-transform.test.ts +17 -11
- package/src/adapters/thread/anthropic/model-invoker.test.ts +4 -3
- package/src/adapters/thread/anthropic/model-invoker.ts +1 -1
- package/src/adapters/thread/anthropic/thread-manager.test.ts +2 -2
- package/src/adapters/thread/anthropic/thread-manager.ts +1 -1
- package/src/adapters/thread/google-genai/activities.ts +1 -1
- package/src/adapters/thread/google-genai/fork-transform.test.ts +17 -11
- package/src/adapters/thread/google-genai/model-invoker.test.ts +337 -0
- package/src/adapters/thread/google-genai/model-invoker.ts +107 -23
- package/src/adapters/thread/google-genai/thread-manager.test.ts +2 -2
- package/src/adapters/thread/google-genai/thread-manager.ts +1 -1
- package/src/adapters/thread/langchain/activities.ts +1 -1
- package/src/adapters/thread/langchain/fork-transform.test.ts +17 -11
- package/src/adapters/thread/langchain/model-invoker.ts +1 -1
- package/src/adapters/thread/langchain/thread-manager.test.ts +2 -2
- package/src/adapters/thread/langchain/thread-manager.ts +1 -1
- package/src/index.ts +2 -2
- package/src/lib/sandbox/capability-types.test.ts +2 -2
- package/src/lib/sandbox/manager.ts +2 -6
- package/src/lib/sandbox/sandbox.test.ts +1 -1
- package/src/lib/sandbox/types.ts +2 -2
- package/src/lib/session/session.integration.test.ts +92 -0
- package/src/lib/session/session.ts +23 -0
- package/src/lib/subagent/handler.ts +23 -0
- package/src/lib/subagent/subagent.integration.test.ts +198 -0
- package/src/lib/thread/keys.test.ts +9 -9
- package/src/lib/thread/keys.ts +1 -1
- package/src/lib/thread/manager.test.ts +24 -14
- package/src/lib/thread/manager.ts +19 -23
- package/src/lib/thread/snapshot.test.ts +51 -43
- package/src/lib/thread/snapshot.ts +54 -32
- package/src/lib/thread/test-utils.ts +106 -59
- package/src/lib/thread/tiered.test.ts +1 -1
- package/src/lib/thread/types.ts +2 -2
- package/src/lib/tool-router/router.integration.test.ts +44 -0
- package/src/lib/tool-router/router.ts +149 -33
- package/src/lib/tool-router/types.ts +23 -0
- package/src/lib/workflow.ts +49 -0
- package/src/{adapters/sandbox/inmemory/proxy.ts → test-utils/in-memory-sandbox-proxy.ts} +5 -16
- package/src/{adapters/sandbox/inmemory/index.ts → test-utils/in-memory-sandbox.ts} +11 -3
- package/src/tools/bash/bash.test.ts +1 -1
- package/src/tools/edit/handler.test.ts +1 -1
- package/tsup.config.ts +2 -4
- package/dist/adapters/sandbox/inmemory/index.cjs +0 -214
- package/dist/adapters/sandbox/inmemory/index.cjs.map +0 -1
- package/dist/adapters/sandbox/inmemory/index.d.cts +0 -40
- package/dist/adapters/sandbox/inmemory/index.d.ts +0 -40
- package/dist/adapters/sandbox/inmemory/index.js +0 -211
- package/dist/adapters/sandbox/inmemory/index.js.map +0 -1
- package/dist/adapters/sandbox/inmemory/workflow.cjs +0 -36
- package/dist/adapters/sandbox/inmemory/workflow.cjs.map +0 -1
- package/dist/adapters/sandbox/inmemory/workflow.d.cts +0 -27
- package/dist/adapters/sandbox/inmemory/workflow.d.ts +0 -27
- package/dist/adapters/sandbox/inmemory/workflow.js +0 -34
- package/dist/adapters/sandbox/inmemory/workflow.js.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
-
import type
|
|
2
|
+
import type { RedisClientType } from "redis";
|
|
3
3
|
import {
|
|
4
4
|
applySnapshot,
|
|
5
5
|
clearHotTier,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "./keys";
|
|
15
15
|
import type { PersistedThreadState } from "../state/types";
|
|
16
16
|
import type { ThreadSnapshot } from "./cold-store";
|
|
17
|
-
import { createFakeRedis } from "./test-utils";
|
|
17
|
+
import { createFakeRedis, makeMultiError } from "./test-utils";
|
|
18
18
|
|
|
19
19
|
const sampleState: PersistedThreadState = {
|
|
20
20
|
tasks: [
|
|
@@ -36,7 +36,7 @@ const sampleState: PersistedThreadState = {
|
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
describe("encodeSnapshot", () => {
|
|
39
|
-
let redis:
|
|
39
|
+
let redis: RedisClientType & { _store: Map<string, unknown> };
|
|
40
40
|
|
|
41
41
|
beforeEach(() => {
|
|
42
42
|
redis = createFakeRedis();
|
|
@@ -97,7 +97,7 @@ describe("encodeSnapshot", () => {
|
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
describe("applySnapshot", () => {
|
|
100
|
-
let redis:
|
|
100
|
+
let redis: RedisClientType & { _store: Map<string, unknown> };
|
|
101
101
|
|
|
102
102
|
beforeEach(() => {
|
|
103
103
|
redis = createFakeRedis();
|
|
@@ -178,7 +178,7 @@ describe("applySnapshot", () => {
|
|
|
178
178
|
const metaKey = getThreadMetaKey("messages", "t-1");
|
|
179
179
|
expect(await redis.exists(metaKey)).toBe(1);
|
|
180
180
|
expect(
|
|
181
|
-
await redis.
|
|
181
|
+
await redis.lRange(getThreadListKey("messages", "t-1"), 0, -1)
|
|
182
182
|
).toEqual([]);
|
|
183
183
|
});
|
|
184
184
|
|
|
@@ -190,7 +190,7 @@ describe("applySnapshot", () => {
|
|
|
190
190
|
const wrapped = new Proxy(redis, {
|
|
191
191
|
get(target, prop, receiver): unknown {
|
|
192
192
|
if (prop === "del") {
|
|
193
|
-
return async (
|
|
193
|
+
return async (_keys: string | string[]): Promise<number> => {
|
|
194
194
|
throw new Error("NOPERM: DEL denied by ACL");
|
|
195
195
|
};
|
|
196
196
|
}
|
|
@@ -206,7 +206,7 @@ describe("applySnapshot", () => {
|
|
|
206
206
|
};
|
|
207
207
|
await expect(
|
|
208
208
|
applySnapshot({
|
|
209
|
-
redis: wrapped as unknown as
|
|
209
|
+
redis: wrapped as unknown as RedisClientType,
|
|
210
210
|
threadKey: "messages",
|
|
211
211
|
threadId: "t-del-fail",
|
|
212
212
|
snapshot: snap,
|
|
@@ -230,42 +230,44 @@ describe("applySnapshot", () => {
|
|
|
230
230
|
});
|
|
231
231
|
|
|
232
232
|
it("clears list/state/dedup residue when a pipelined write fails partway", async () => {
|
|
233
|
-
//
|
|
234
|
-
// underlying fake (so residue is observable) and errors on
|
|
233
|
+
// `multi()` stub that *applies* non-failing commands to the
|
|
234
|
+
// underlying fake (so residue is observable) and errors on rPush
|
|
235
235
|
// — mimicking a partial OOM where the list write fails but the
|
|
236
236
|
// dedup SETs queued after it still land. Without compensating
|
|
237
237
|
// cleanup, those stale dedup keys could silently skip a future
|
|
238
|
-
// append with the same id.
|
|
238
|
+
// append with the same id. node-redis rejects `execAsPipeline`
|
|
239
|
+
// with a `MultiErrorReply` carrying per-command errors.
|
|
239
240
|
const wrapped = new Proxy(redis, {
|
|
240
241
|
get(target, prop, receiver): unknown {
|
|
241
|
-
if (prop === "
|
|
242
|
+
if (prop === "multi") {
|
|
242
243
|
return (): Record<string, unknown> => {
|
|
243
244
|
type Op = { method: string; args: unknown[] };
|
|
244
245
|
const ops: Op[] = [];
|
|
245
246
|
const chain: Record<string, unknown> = {};
|
|
246
|
-
for (const m of ["set", "del", "
|
|
247
|
+
for (const m of ["set", "del", "rPush", "expire"]) {
|
|
247
248
|
chain[m] = (...args: unknown[]): Record<string, unknown> => {
|
|
248
249
|
ops.push({ method: m, args });
|
|
249
250
|
return chain;
|
|
250
251
|
};
|
|
251
252
|
}
|
|
252
|
-
chain.
|
|
253
|
-
const
|
|
253
|
+
chain.execAsPipeline = async (): Promise<unknown[]> => {
|
|
254
|
+
const replies: unknown[] = [];
|
|
255
|
+
const errorIndexes: number[] = [];
|
|
254
256
|
const callable = target as unknown as Record<
|
|
255
257
|
string,
|
|
256
258
|
(...a: unknown[]) => Promise<unknown>
|
|
257
259
|
>;
|
|
258
|
-
for (const op of ops) {
|
|
259
|
-
if (op.method === "
|
|
260
|
-
|
|
260
|
+
for (const [i, op] of ops.entries()) {
|
|
261
|
+
if (op.method === "rPush") {
|
|
262
|
+
replies.push(new Error("OOM"));
|
|
263
|
+
errorIndexes.push(i);
|
|
261
264
|
continue;
|
|
262
265
|
}
|
|
263
266
|
const fn = callable[op.method];
|
|
264
267
|
if (!fn) throw new Error(`stub: unknown ${op.method}`);
|
|
265
|
-
|
|
266
|
-
out.push([null, result]);
|
|
268
|
+
replies.push(await fn(...op.args));
|
|
267
269
|
}
|
|
268
|
-
|
|
270
|
+
throw makeMultiError(replies, errorIndexes);
|
|
269
271
|
};
|
|
270
272
|
return chain;
|
|
271
273
|
};
|
|
@@ -282,7 +284,7 @@ describe("applySnapshot", () => {
|
|
|
282
284
|
};
|
|
283
285
|
await expect(
|
|
284
286
|
applySnapshot({
|
|
285
|
-
redis: wrapped as unknown as
|
|
287
|
+
redis: wrapped as unknown as RedisClientType,
|
|
286
288
|
threadKey: "messages",
|
|
287
289
|
threadId: "t-residue",
|
|
288
290
|
snapshot: snap,
|
|
@@ -305,30 +307,36 @@ describe("applySnapshot", () => {
|
|
|
305
307
|
});
|
|
306
308
|
|
|
307
309
|
it("throws and leaves the meta key unset when a pipelined command fails", async () => {
|
|
308
|
-
// Wrap the fake's `
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
// result tuple, and applySnapshot must surface it.
|
|
310
|
+
// Wrap the fake's `multi()` so `execAsPipeline()` rejects with a
|
|
311
|
+
// `MultiErrorReply` — mimicking node-redis's behaviour when Redis
|
|
312
|
+
// runtime errors (OOM, ACL, WRONGTYPE) occur inside a pipeline.
|
|
313
|
+
// `applySnapshot` must surface the underlying error.
|
|
313
314
|
const wrapped = new Proxy(redis, {
|
|
314
315
|
get(target, prop, receiver): unknown {
|
|
315
|
-
if (prop === "
|
|
316
|
+
if (prop === "multi") {
|
|
316
317
|
return (): Record<string, unknown> => {
|
|
317
318
|
type Op = { method: string; args: unknown[] };
|
|
318
319
|
const ops: Op[] = [];
|
|
319
320
|
const chain: Record<string, unknown> = {};
|
|
320
|
-
for (const m of ["set", "del", "
|
|
321
|
+
for (const m of ["set", "del", "rPush", "expire"]) {
|
|
321
322
|
chain[m] = (...args: unknown[]): Record<string, unknown> => {
|
|
322
323
|
ops.push({ method: m, args });
|
|
323
324
|
return chain;
|
|
324
325
|
};
|
|
325
326
|
}
|
|
326
|
-
chain.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
327
|
+
chain.execAsPipeline = async (): Promise<unknown[]> => {
|
|
328
|
+
const replies: unknown[] = [];
|
|
329
|
+
const errorIndexes: number[] = [];
|
|
330
|
+
ops.forEach((op, i) => {
|
|
331
|
+
if (op.method === "rPush") {
|
|
332
|
+
replies.push(new Error("OOM command not allowed"));
|
|
333
|
+
errorIndexes.push(i);
|
|
334
|
+
} else {
|
|
335
|
+
replies.push(i);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
throw makeMultiError(replies, errorIndexes);
|
|
339
|
+
};
|
|
332
340
|
return chain;
|
|
333
341
|
};
|
|
334
342
|
}
|
|
@@ -344,7 +352,7 @@ describe("applySnapshot", () => {
|
|
|
344
352
|
};
|
|
345
353
|
await expect(
|
|
346
354
|
applySnapshot({
|
|
347
|
-
redis: wrapped as unknown as
|
|
355
|
+
redis: wrapped as unknown as RedisClientType,
|
|
348
356
|
threadKey: "messages",
|
|
349
357
|
threadId: "t-fail",
|
|
350
358
|
snapshot: snap,
|
|
@@ -354,14 +362,14 @@ describe("applySnapshot", () => {
|
|
|
354
362
|
expect(await redis.exists(getThreadMetaKey("messages", "t-fail"))).toBe(0);
|
|
355
363
|
});
|
|
356
364
|
|
|
357
|
-
it("issues a single
|
|
358
|
-
let
|
|
365
|
+
it("issues a single multi().execAsPipeline() rather than per-key writes", async () => {
|
|
366
|
+
let multiCalls = 0;
|
|
359
367
|
const wrapped = new Proxy(redis, {
|
|
360
368
|
get(target, prop, receiver): unknown {
|
|
361
|
-
if (prop === "
|
|
369
|
+
if (prop === "multi") {
|
|
362
370
|
return (): unknown => {
|
|
363
|
-
|
|
364
|
-
return (target as unknown as {
|
|
371
|
+
multiCalls++;
|
|
372
|
+
return (target as unknown as { multi: () => unknown }).multi();
|
|
365
373
|
};
|
|
366
374
|
}
|
|
367
375
|
return Reflect.get(target, prop, receiver) as unknown;
|
|
@@ -377,19 +385,19 @@ describe("applySnapshot", () => {
|
|
|
377
385
|
dedupIds: Array.from({ length: 10 }, (_, i) => `m${i}`),
|
|
378
386
|
};
|
|
379
387
|
await applySnapshot({
|
|
380
|
-
redis: wrapped as unknown as
|
|
388
|
+
redis: wrapped as unknown as RedisClientType,
|
|
381
389
|
threadKey: "messages",
|
|
382
390
|
threadId: "t-1",
|
|
383
391
|
snapshot: snap,
|
|
384
392
|
});
|
|
385
393
|
|
|
386
|
-
expect(
|
|
394
|
+
expect(multiCalls).toBe(1);
|
|
387
395
|
});
|
|
388
396
|
|
|
389
397
|
it("clears any partial residue from a previous failed restore", async () => {
|
|
390
398
|
const listKey = getThreadListKey("messages", "t-1");
|
|
391
399
|
const stateKey = getThreadStateKey("messages", "t-1");
|
|
392
|
-
await redis.
|
|
400
|
+
await redis.rPush(listKey, "stale-message");
|
|
393
401
|
await redis.set(stateKey, JSON.stringify({ stale: true }));
|
|
394
402
|
// Note: meta is intentionally absent — simulates a half-written restore.
|
|
395
403
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* tiered thread manager in `tiered.ts` is the only consumer.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type
|
|
11
|
+
import type { RedisClientType } from "redis";
|
|
12
12
|
import type { PersistedThreadState } from "../state/types";
|
|
13
13
|
import type { ThreadSnapshot } from "./cold-store";
|
|
14
14
|
import {
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
|
|
22
22
|
/** Inputs shared by every snapshot operation. */
|
|
23
23
|
interface SnapshotCommon {
|
|
24
|
-
redis:
|
|
24
|
+
redis: RedisClientType;
|
|
25
25
|
threadKey: string;
|
|
26
26
|
threadId: string;
|
|
27
27
|
}
|
|
@@ -53,7 +53,7 @@ export async function encodeSnapshot(
|
|
|
53
53
|
}
|
|
54
54
|
const listKey = getThreadListKey(threadKey, threadId);
|
|
55
55
|
const stateKey = getThreadStateKey(threadKey, threadId);
|
|
56
|
-
const messages = await redis.
|
|
56
|
+
const messages = await redis.lRange(listKey, 0, -1);
|
|
57
57
|
const stateRaw = await redis.get(stateKey);
|
|
58
58
|
const state =
|
|
59
59
|
stateRaw == null ? null : (JSON.parse(stateRaw) as PersistedThreadState);
|
|
@@ -99,43 +99,65 @@ export async function applySnapshot(
|
|
|
99
99
|
// CROSSSLOT, …) short-circuits before any writes hit the wire —
|
|
100
100
|
// pipelines are non-atomic, so a queued DEL wouldn't stop later
|
|
101
101
|
// commands from accumulating data behind a missing meta marker.
|
|
102
|
-
await redis.del(listKey, stateKey);
|
|
102
|
+
await redis.del([listKey, stateKey]);
|
|
103
103
|
|
|
104
|
-
// Pipeline the data writes (list/state/dedup) in one round-trip
|
|
105
|
-
// Meta is written
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// retries cleanly.
|
|
109
|
-
const pipeline = redis.
|
|
104
|
+
// Pipeline the data writes (list/state/dedup) in one round-trip via a
|
|
105
|
+
// non-transactional `MULTI` (`execAsPipeline`). Meta is written
|
|
106
|
+
// separately, only after every queued command succeeded, preserving
|
|
107
|
+
// the "meta-last" crash-safety invariant — a partial restore must
|
|
108
|
+
// leave meta absent so the next hydrate retries cleanly.
|
|
109
|
+
const pipeline = redis.multi();
|
|
110
110
|
if (snapshot.messages.length > 0) {
|
|
111
|
-
pipeline.
|
|
111
|
+
pipeline.rPush(listKey, snapshot.messages);
|
|
112
112
|
pipeline.expire(listKey, ttlSeconds);
|
|
113
113
|
}
|
|
114
114
|
if (snapshot.state != null) {
|
|
115
|
-
pipeline.set(stateKey, JSON.stringify(snapshot.state),
|
|
115
|
+
pipeline.set(stateKey, JSON.stringify(snapshot.state), { EX: ttlSeconds });
|
|
116
116
|
}
|
|
117
117
|
for (const id of snapshot.dedupIds) {
|
|
118
|
-
pipeline.set(getThreadDedupKey(threadId, id), "1",
|
|
118
|
+
pipeline.set(getThreadDedupKey(threadId, id), "1", { EX: ttlSeconds });
|
|
119
119
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
120
|
+
try {
|
|
121
|
+
await pipeline.execAsPipeline();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// Compensate: pipelines are non-atomic, so writes queued after a
|
|
124
|
+
// failing command (notably dedup SETs) may have landed. Best-effort
|
|
125
|
+
// clear every key we touched so a leftover dedup marker can't
|
|
126
|
+
// silently skip a future append with the same id. node-redis
|
|
127
|
+
// surfaces per-command failures by rejecting `execAsPipeline` with a
|
|
128
|
+
// `MultiErrorReply`; we unwrap it to rethrow the first real error.
|
|
129
|
+
await redis
|
|
130
|
+
.del([
|
|
131
|
+
listKey,
|
|
132
|
+
stateKey,
|
|
133
|
+
...snapshot.dedupIds.map((id) => getThreadDedupKey(threadId, id)),
|
|
134
|
+
])
|
|
135
|
+
.catch(() => undefined);
|
|
136
|
+
throw firstPipelineError(err);
|
|
137
137
|
}
|
|
138
|
-
await redis.set(metaKey, "1",
|
|
138
|
+
await redis.set(metaKey, "1", { EX: ttlSeconds });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Unwrap node-redis's `MultiErrorReply` (thrown by `execAsPipeline` when
|
|
143
|
+
* one or more queued commands fail) to the first underlying error so
|
|
144
|
+
* callers see the actual Redis error (OOM, WRONGTYPE, …) rather than the
|
|
145
|
+
* generic aggregate wrapper. The structural check avoids a hard runtime
|
|
146
|
+
* dependency on the `redis` error class.
|
|
147
|
+
*/
|
|
148
|
+
function firstPipelineError(err: unknown): unknown {
|
|
149
|
+
if (
|
|
150
|
+
err != null &&
|
|
151
|
+
typeof err === "object" &&
|
|
152
|
+
"replies" in err &&
|
|
153
|
+
Array.isArray((err as { replies: unknown }).replies)
|
|
154
|
+
) {
|
|
155
|
+
const firstErr = (err as { replies: unknown[] }).replies.find(
|
|
156
|
+
(r): r is Error => r instanceof Error
|
|
157
|
+
);
|
|
158
|
+
if (firstErr) return firstErr;
|
|
159
|
+
}
|
|
160
|
+
return err;
|
|
139
161
|
}
|
|
140
162
|
|
|
141
163
|
/** Configuration for {@link clearHotTier}. */
|
|
@@ -159,5 +181,5 @@ export async function clearHotTier(
|
|
|
159
181
|
getThreadStateKey(threadKey, threadId),
|
|
160
182
|
...dedupIds.map((id) => getThreadDedupKey(threadId, id)),
|
|
161
183
|
];
|
|
162
|
-
await redis.del(
|
|
184
|
+
await redis.del(keys);
|
|
163
185
|
}
|
|
@@ -7,19 +7,35 @@
|
|
|
7
7
|
* picks it up directly.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type
|
|
10
|
+
import type { RedisClientType } from "redis";
|
|
11
11
|
import type { ColdThreadStore, ThreadSnapshot } from "./cold-store";
|
|
12
12
|
|
|
13
13
|
type Value = string | string[];
|
|
14
14
|
|
|
15
|
+
/** node-redis `SetOptions` subset the stub understands. */
|
|
16
|
+
interface FakeSetOptions {
|
|
17
|
+
EX?: number;
|
|
18
|
+
NX?: boolean;
|
|
19
|
+
expiration?: { type: "EX" | "PX" | "EXAT" | "PXAT"; value: number } | "KEEPTTL";
|
|
20
|
+
condition?: "NX" | "XX";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** node-redis accepts a single key or an array (`RedisVariadicArgument`). */
|
|
24
|
+
type Keys = string | string[];
|
|
25
|
+
const toKeys = (keys: Keys): string[] => (Array.isArray(keys) ? keys : [keys]);
|
|
26
|
+
|
|
15
27
|
/**
|
|
16
|
-
* Minimal in-memory
|
|
28
|
+
* Minimal in-memory node-redis stub covering the commands the thread
|
|
17
29
|
* manager + snapshot helpers use: get/set/del/exists/expire,
|
|
18
|
-
*
|
|
19
|
-
* script.
|
|
20
|
-
*
|
|
30
|
+
* lRange/rPush/lLen/lTrim, and the `eval`-based idempotent-append Lua
|
|
31
|
+
* script. Mirrors the node-redis (`redis`) v4+ API surface — camelCase
|
|
32
|
+
* commands, an options object for `set`, variadic-or-array keys for
|
|
33
|
+
* `del`/`exists`, and a `multi().execAsPipeline()` pipeline that rejects
|
|
34
|
+
* with a `MultiErrorReply`-shaped error when a queued command fails.
|
|
35
|
+
* Behaviour matches Redis closely enough for unit tests; TTLs are stored
|
|
36
|
+
* but never expire automatically.
|
|
21
37
|
*/
|
|
22
|
-
export function createFakeRedis():
|
|
38
|
+
export function createFakeRedis(): RedisClientType & {
|
|
23
39
|
_store: Map<string, Value>;
|
|
24
40
|
_ttls: Map<string, number>;
|
|
25
41
|
} {
|
|
@@ -48,56 +64,60 @@ export function createFakeRedis(): Redis & {
|
|
|
48
64
|
async set(
|
|
49
65
|
key: string,
|
|
50
66
|
value: string,
|
|
51
|
-
|
|
52
|
-
): Promise<"OK"> {
|
|
53
|
-
// NX guard: when the
|
|
67
|
+
options?: FakeSetOptions
|
|
68
|
+
): Promise<"OK" | null> {
|
|
69
|
+
// NX guard: when the condition is NX and the key already exists,
|
|
54
70
|
// Redis returns null. We follow the same contract for tests that
|
|
55
|
-
// need it
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
return null
|
|
71
|
+
// need it.
|
|
72
|
+
const nx = options?.NX === true || options?.condition === "NX";
|
|
73
|
+
if (nx && store.has(key)) {
|
|
74
|
+
return null;
|
|
59
75
|
}
|
|
60
76
|
store.set(key, String(value));
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
const ttl =
|
|
78
|
+
options?.EX ??
|
|
79
|
+
(options?.expiration && options.expiration !== "KEEPTTL"
|
|
80
|
+
? options.expiration.value
|
|
81
|
+
: undefined);
|
|
82
|
+
if (typeof ttl === "number") {
|
|
83
|
+
ttls.set(key, ttl);
|
|
64
84
|
}
|
|
65
85
|
return "OK";
|
|
66
86
|
},
|
|
67
|
-
async del(
|
|
87
|
+
async del(keys: Keys): Promise<number> {
|
|
68
88
|
let removed = 0;
|
|
69
|
-
for (const k of keys) {
|
|
89
|
+
for (const k of toKeys(keys)) {
|
|
70
90
|
if (store.delete(k)) removed++;
|
|
71
91
|
ttls.delete(k);
|
|
72
92
|
}
|
|
73
93
|
return removed;
|
|
74
94
|
},
|
|
75
|
-
async exists(
|
|
76
|
-
return keys.reduce((acc, k) => acc + (store.has(k) ? 1 : 0), 0);
|
|
95
|
+
async exists(keys: Keys): Promise<number> {
|
|
96
|
+
return toKeys(keys).reduce((acc, k) => acc + (store.has(k) ? 1 : 0), 0);
|
|
77
97
|
},
|
|
78
98
|
async expire(key: string, ttl: number): Promise<number> {
|
|
79
99
|
if (!store.has(key)) return 0;
|
|
80
100
|
ttls.set(key, ttl);
|
|
81
101
|
return 1;
|
|
82
102
|
},
|
|
83
|
-
async
|
|
103
|
+
async lRange(key: string, start: number, end: number): Promise<string[]> {
|
|
84
104
|
if (!store.has(key)) return [];
|
|
85
105
|
if (!isList(key)) return [];
|
|
86
106
|
const list = store.get(key) as string[];
|
|
87
107
|
const last = end === -1 ? list.length - 1 : end;
|
|
88
108
|
return list.slice(start, last + 1);
|
|
89
109
|
},
|
|
90
|
-
async
|
|
110
|
+
async rPush(key: string, element: Keys): Promise<number> {
|
|
91
111
|
const list = ensureList(key);
|
|
92
|
-
list.push(...
|
|
112
|
+
list.push(...toKeys(element));
|
|
93
113
|
return list.length;
|
|
94
114
|
},
|
|
95
|
-
async
|
|
115
|
+
async lLen(key: string): Promise<number> {
|
|
96
116
|
if (!store.has(key)) return 0;
|
|
97
117
|
const list = store.get(key) as string[];
|
|
98
118
|
return list.length;
|
|
99
119
|
},
|
|
100
|
-
async
|
|
120
|
+
async lTrim(key: string, start: number, end: number): Promise<"OK"> {
|
|
101
121
|
if (!store.has(key)) return "OK";
|
|
102
122
|
const list = store.get(key) as string[];
|
|
103
123
|
const last = end === -1 ? list.length - 1 : end;
|
|
@@ -106,12 +126,11 @@ export function createFakeRedis(): Redis & {
|
|
|
106
126
|
},
|
|
107
127
|
async eval(
|
|
108
128
|
_script: string,
|
|
109
|
-
|
|
110
|
-
...args: (string | number)[]
|
|
129
|
+
options: { keys?: string[]; arguments?: string[] }
|
|
111
130
|
): Promise<number> {
|
|
112
131
|
// Mirrors APPEND_IDEMPOTENT_SCRIPT in src/lib/thread/manager.ts.
|
|
113
|
-
const keys =
|
|
114
|
-
const argv =
|
|
132
|
+
const keys = options.keys ?? [];
|
|
133
|
+
const argv = options.arguments ?? [];
|
|
115
134
|
const dedupKey = keys[0];
|
|
116
135
|
const listKey = keys[1];
|
|
117
136
|
const ttl = Number(argv[0]);
|
|
@@ -127,54 +146,64 @@ export function createFakeRedis(): Redis & {
|
|
|
127
146
|
ttls.set(dedupKey, ttl);
|
|
128
147
|
return 1;
|
|
129
148
|
},
|
|
130
|
-
// Chainable
|
|
131
|
-
// sync fake methods on `.
|
|
132
|
-
// semantics stay identical to the non-pipelined path.
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
149
|
+
// Chainable `multi()` stub. Defers each command to the underlying
|
|
150
|
+
// sync fake methods on `.execAsPipeline()`, so TTL tracking and store
|
|
151
|
+
// semantics stay identical to the non-pipelined path. Mirrors
|
|
152
|
+
// node-redis: per-command failures reject the pipeline with a
|
|
153
|
+
// `MultiErrorReply`-shaped error (`{ replies, errorIndexes }`).
|
|
154
|
+
multi(): FakeMulti {
|
|
136
155
|
const impl = fake as unknown as {
|
|
137
|
-
set: (
|
|
138
|
-
|
|
139
|
-
|
|
156
|
+
set: (
|
|
157
|
+
key: string,
|
|
158
|
+
value: string,
|
|
159
|
+
options?: FakeSetOptions
|
|
160
|
+
) => Promise<"OK" | null>;
|
|
161
|
+
del: (keys: Keys) => Promise<number>;
|
|
162
|
+
rPush: (key: string, element: Keys) => Promise<number>;
|
|
140
163
|
expire: (key: string, ttl: number) => Promise<number>;
|
|
141
164
|
};
|
|
142
165
|
const ops: Array<() => Promise<unknown>> = [];
|
|
143
|
-
const chain:
|
|
144
|
-
set: (
|
|
145
|
-
|
|
146
|
-
ops.push(() => impl.set(key, value, ...rest));
|
|
166
|
+
const chain: FakeMulti = {
|
|
167
|
+
set: (key, value, options) => {
|
|
168
|
+
ops.push(() => impl.set(key, value, options));
|
|
147
169
|
return chain;
|
|
148
170
|
},
|
|
149
|
-
del: (
|
|
150
|
-
ops.push(() => impl.del(
|
|
171
|
+
del: (keys) => {
|
|
172
|
+
ops.push(() => impl.del(keys));
|
|
151
173
|
return chain;
|
|
152
174
|
},
|
|
153
|
-
|
|
154
|
-
ops.push(() => impl.
|
|
175
|
+
rPush: (key, element) => {
|
|
176
|
+
ops.push(() => impl.rPush(key, element));
|
|
155
177
|
return chain;
|
|
156
178
|
},
|
|
157
179
|
expire: (key, ttl) => {
|
|
158
180
|
ops.push(() => impl.expire(key, ttl));
|
|
159
181
|
return chain;
|
|
160
182
|
},
|
|
161
|
-
|
|
162
|
-
const
|
|
183
|
+
execAsPipeline: async () => {
|
|
184
|
+
const replies: unknown[] = [];
|
|
185
|
+
const errorIndexes: number[] = [];
|
|
186
|
+
let i = 0;
|
|
163
187
|
for (const op of ops) {
|
|
164
188
|
try {
|
|
165
|
-
|
|
189
|
+
replies.push(await op());
|
|
166
190
|
} catch (e) {
|
|
167
|
-
|
|
191
|
+
replies.push(e);
|
|
192
|
+
errorIndexes.push(i);
|
|
168
193
|
}
|
|
194
|
+
i++;
|
|
195
|
+
}
|
|
196
|
+
if (errorIndexes.length > 0) {
|
|
197
|
+
throw makeMultiError(replies, errorIndexes);
|
|
169
198
|
}
|
|
170
|
-
return
|
|
199
|
+
return replies;
|
|
171
200
|
},
|
|
172
201
|
};
|
|
173
202
|
return chain;
|
|
174
203
|
},
|
|
175
204
|
_store: store,
|
|
176
205
|
_ttls: ttls,
|
|
177
|
-
} as unknown as
|
|
206
|
+
} as unknown as RedisClientType & {
|
|
178
207
|
_store: Map<string, Value>;
|
|
179
208
|
_ttls: Map<string, number>;
|
|
180
209
|
};
|
|
@@ -182,13 +211,31 @@ export function createFakeRedis(): Redis & {
|
|
|
182
211
|
return fake;
|
|
183
212
|
}
|
|
184
213
|
|
|
185
|
-
/** Minimal chainable surface used by the fake-redis
|
|
186
|
-
interface
|
|
187
|
-
set: (
|
|
188
|
-
del: (
|
|
189
|
-
|
|
190
|
-
expire: (key: string, ttl: number) =>
|
|
191
|
-
|
|
214
|
+
/** Minimal chainable surface used by the fake-redis `multi()` stub. */
|
|
215
|
+
interface FakeMulti {
|
|
216
|
+
set: (key: string, value: string, options?: FakeSetOptions) => FakeMulti;
|
|
217
|
+
del: (keys: Keys) => FakeMulti;
|
|
218
|
+
rPush: (key: string, element: Keys) => FakeMulti;
|
|
219
|
+
expire: (key: string, ttl: number) => FakeMulti;
|
|
220
|
+
execAsPipeline: () => Promise<unknown[]>;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Build a node-redis `MultiErrorReply`-shaped error: an `Error` carrying
|
|
225
|
+
* `replies` (per-command results, with failures as `Error`s) and
|
|
226
|
+
* `errorIndexes`. `applySnapshot` unwraps this to surface the first real
|
|
227
|
+
* error.
|
|
228
|
+
*/
|
|
229
|
+
export function makeMultiError(
|
|
230
|
+
replies: unknown[],
|
|
231
|
+
errorIndexes: number[]
|
|
232
|
+
): Error & { replies: unknown[]; errorIndexes: number[] } {
|
|
233
|
+
return Object.assign(
|
|
234
|
+
new Error(
|
|
235
|
+
`${errorIndexes.length} commands failed, see .replies and .errorIndexes for more information`
|
|
236
|
+
),
|
|
237
|
+
{ replies, errorIndexes }
|
|
238
|
+
);
|
|
192
239
|
}
|
|
193
240
|
|
|
194
241
|
/**
|
package/src/lib/thread/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import type { RedisClientType } from "redis";
|
|
2
2
|
import type { JsonValue, PersistedThreadState } from "../state/types";
|
|
3
3
|
export interface ThreadManagerConfig<T> {
|
|
4
|
-
redis:
|
|
4
|
+
redis: RedisClientType;
|
|
5
5
|
threadId: string;
|
|
6
6
|
/** Thread key, defaults to 'messages' */
|
|
7
7
|
key?: string;
|