zeitlich 0.2.37 → 0.2.39
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 +18 -0
- package/dist/{activities-Bb-nAjwQ.d.ts → activities-Bmu7XnaG.d.ts} +4 -4
- package/dist/{activities-vkI4_3CC.d.cts → activities-ByBFLvm2.d.cts} +4 -4
- package/dist/adapter-id-BB-mmrts.d.cts +17 -0
- package/dist/adapter-id-BB-mmrts.d.ts +17 -0
- package/dist/adapter-id-CMwVrVqv.d.cts +17 -0
- package/dist/adapter-id-CMwVrVqv.d.ts +17 -0
- package/dist/adapter-id-CbY2zeSt.d.cts +17 -0
- package/dist/adapter-id-CbY2zeSt.d.ts +17 -0
- package/dist/adapters/sandbox/bedrock/index.cjs +3 -3
- package/dist/adapters/sandbox/bedrock/index.cjs.map +1 -1
- package/dist/adapters/sandbox/bedrock/index.d.cts +6 -6
- package/dist/adapters/sandbox/bedrock/index.d.ts +6 -6
- package/dist/adapters/sandbox/bedrock/index.js +3 -3
- package/dist/adapters/sandbox/bedrock/index.js.map +1 -1
- package/dist/adapters/sandbox/bedrock/workflow.d.cts +2 -2
- package/dist/adapters/sandbox/bedrock/workflow.d.ts +2 -2
- package/dist/adapters/sandbox/daytona/index.cjs +3 -3
- package/dist/adapters/sandbox/daytona/index.cjs.map +1 -1
- package/dist/adapters/sandbox/daytona/index.d.cts +4 -4
- package/dist/adapters/sandbox/daytona/index.d.ts +4 -4
- package/dist/adapters/sandbox/daytona/index.js +3 -3
- package/dist/adapters/sandbox/daytona/index.js.map +1 -1
- package/dist/adapters/sandbox/daytona/workflow.d.cts +1 -1
- package/dist/adapters/sandbox/daytona/workflow.d.ts +1 -1
- package/dist/adapters/sandbox/e2b/index.cjs +26 -14
- package/dist/adapters/sandbox/e2b/index.cjs.map +1 -1
- package/dist/adapters/sandbox/e2b/index.d.cts +24 -4
- package/dist/adapters/sandbox/e2b/index.d.ts +24 -4
- package/dist/adapters/sandbox/e2b/index.js +26 -14
- 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/sandbox/inmemory/index.cjs +3 -3
- package/dist/adapters/sandbox/inmemory/index.cjs.map +1 -1
- package/dist/adapters/sandbox/inmemory/index.d.cts +4 -4
- package/dist/adapters/sandbox/inmemory/index.d.ts +4 -4
- package/dist/adapters/sandbox/inmemory/index.js +3 -3
- package/dist/adapters/sandbox/inmemory/index.js.map +1 -1
- package/dist/adapters/sandbox/inmemory/workflow.d.cts +1 -1
- package/dist/adapters/sandbox/inmemory/workflow.d.ts +1 -1
- package/dist/adapters/thread/anthropic/index.cjs +150 -13
- package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/index.d.cts +9 -8
- package/dist/adapters/thread/anthropic/index.d.ts +9 -8
- package/dist/adapters/thread/anthropic/index.js +150 -14
- package/dist/adapters/thread/anthropic/index.js.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.cjs +9 -3
- package/dist/adapters/thread/anthropic/workflow.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.d.cts +6 -5
- package/dist/adapters/thread/anthropic/workflow.d.ts +6 -5
- package/dist/adapters/thread/anthropic/workflow.js +9 -4
- package/dist/adapters/thread/anthropic/workflow.js.map +1 -1
- package/dist/adapters/thread/google-genai/index.cjs +154 -13
- package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/index.d.cts +6 -5
- package/dist/adapters/thread/google-genai/index.d.ts +6 -5
- package/dist/adapters/thread/google-genai/index.js +154 -14
- package/dist/adapters/thread/google-genai/index.js.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.cjs +9 -3
- package/dist/adapters/thread/google-genai/workflow.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.d.cts +6 -5
- package/dist/adapters/thread/google-genai/workflow.d.ts +6 -5
- package/dist/adapters/thread/google-genai/workflow.js +9 -4
- package/dist/adapters/thread/google-genai/workflow.js.map +1 -1
- package/dist/adapters/thread/index.cjs +16 -0
- package/dist/adapters/thread/index.cjs.map +1 -0
- package/dist/adapters/thread/index.d.cts +34 -0
- package/dist/adapters/thread/index.d.ts +34 -0
- package/dist/adapters/thread/index.js +12 -0
- package/dist/adapters/thread/index.js.map +1 -0
- package/dist/adapters/thread/langchain/index.cjs +149 -14
- package/dist/adapters/thread/langchain/index.cjs.map +1 -1
- package/dist/adapters/thread/langchain/index.d.cts +9 -8
- package/dist/adapters/thread/langchain/index.d.ts +9 -8
- package/dist/adapters/thread/langchain/index.js +149 -15
- package/dist/adapters/thread/langchain/index.js.map +1 -1
- package/dist/adapters/thread/langchain/workflow.cjs +9 -3
- package/dist/adapters/thread/langchain/workflow.cjs.map +1 -1
- package/dist/adapters/thread/langchain/workflow.d.cts +6 -5
- package/dist/adapters/thread/langchain/workflow.d.ts +6 -5
- package/dist/adapters/thread/langchain/workflow.js +9 -4
- package/dist/adapters/thread/langchain/workflow.js.map +1 -1
- package/dist/index.cjs +367 -59
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +11 -11
- package/dist/index.d.ts +11 -11
- package/dist/index.js +365 -61
- package/dist/index.js.map +1 -1
- package/dist/{proxy-DEtowJyd.d.cts → proxy-BAKzNGRq.d.cts} +1 -1
- package/dist/{proxy-0smGKvx8.d.ts → proxy-DO_MXbY4.d.ts} +1 -1
- package/dist/{thread-manager-C-C4pI2z.d.ts → thread-manager-CcRXasqs.d.ts} +2 -2
- package/dist/{thread-manager-D4vgzYrh.d.cts → thread-manager-ClwSaUnj.d.cts} +2 -2
- package/dist/{thread-manager-3fszQih4.d.ts → thread-manager-D-7lp1JK.d.ts} +2 -2
- package/dist/{thread-manager-CzYln2OC.d.cts → thread-manager-Y8Ucf0Tf.d.cts} +2 -2
- package/dist/{types-CPKDl-y_.d.ts → types-Bcbiq8iv.d.cts} +195 -22
- package/dist/{types-CNuWnvy9.d.ts → types-DAsQ21Rt.d.ts} +1 -1
- package/dist/{types-B37hKoWA.d.ts → types-DpHTX-iO.d.ts} +58 -1
- package/dist/{types-BO7Yju20.d.cts → types-Dt8-HBBT.d.ts} +195 -22
- package/dist/{types-D08CXPh8.d.cts → types-hFFi-Zd9.d.cts} +58 -1
- package/dist/{types-DWEUmYAJ.d.cts → types-lm8tMNJQ.d.cts} +1 -1
- package/dist/{types-tQL9njTu.d.cts → types-yx0LzPGn.d.cts} +21 -7
- package/dist/{types-tQL9njTu.d.ts → types-yx0LzPGn.d.ts} +21 -7
- package/dist/{workflow-CjXHbZZc.d.ts → workflow-Bmf9EtDW.d.ts} +83 -3
- package/dist/{workflow-Do_lzJpT.d.cts → workflow-Bx9utBwb.d.cts} +83 -3
- package/dist/workflow.cjs +266 -39
- 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 +264 -41
- package/dist/workflow.js.map +1 -1
- package/package.json +12 -2
- package/src/adapters/sandbox/bedrock/index.ts +12 -3
- package/src/adapters/sandbox/daytona/index.ts +12 -3
- package/src/adapters/sandbox/e2b/index.ts +36 -14
- package/src/adapters/sandbox/e2b/types.ts +16 -0
- package/src/adapters/sandbox/inmemory/index.ts +12 -3
- package/src/adapters/thread/adapter-id.test.ts +42 -0
- package/src/adapters/thread/anthropic/activities.ts +40 -5
- package/src/adapters/thread/anthropic/adapter-id.ts +16 -0
- package/src/adapters/thread/anthropic/fork-transform.test.ts +291 -0
- package/src/adapters/thread/anthropic/index.ts +3 -0
- package/src/adapters/thread/anthropic/model-invoker.ts +7 -1
- package/src/adapters/thread/anthropic/proxy.ts +3 -2
- package/src/adapters/thread/anthropic/thread-manager.ts +27 -1
- package/src/adapters/thread/google-genai/activities.ts +44 -5
- package/src/adapters/thread/google-genai/adapter-id.ts +16 -0
- package/src/adapters/thread/google-genai/fork-transform.test.ts +149 -0
- package/src/adapters/thread/google-genai/index.ts +3 -0
- package/src/adapters/thread/google-genai/model-invoker.ts +8 -2
- package/src/adapters/thread/google-genai/proxy.ts +3 -2
- package/src/adapters/thread/google-genai/thread-manager.ts +27 -1
- package/src/adapters/thread/index.ts +39 -0
- package/src/adapters/thread/langchain/activities.ts +40 -5
- package/src/adapters/thread/langchain/adapter-id.ts +16 -0
- package/src/adapters/thread/langchain/fork-transform.test.ts +142 -0
- package/src/adapters/thread/langchain/index.ts +3 -0
- package/src/adapters/thread/langchain/model-invoker.ts +7 -1
- package/src/adapters/thread/langchain/proxy.ts +3 -2
- package/src/adapters/thread/langchain/thread-manager.ts +27 -1
- package/src/lib/lifecycle.ts +14 -5
- package/src/lib/model/types.ts +7 -0
- package/src/lib/sandbox/manager.ts +26 -18
- package/src/lib/sandbox/types.ts +27 -7
- package/src/lib/session/session-edge-cases.integration.test.ts +336 -4
- package/src/lib/session/session.integration.test.ts +192 -2
- package/src/lib/session/session.ts +102 -8
- package/src/lib/session/types.ts +66 -3
- package/src/lib/state/index.ts +1 -0
- package/src/lib/state/manager.integration.test.ts +109 -0
- package/src/lib/state/manager.ts +38 -8
- package/src/lib/state/types.ts +25 -0
- package/src/lib/subagent/handler.ts +124 -11
- package/src/lib/subagent/index.ts +5 -1
- package/src/lib/subagent/subagent.integration.test.ts +628 -104
- package/src/lib/subagent/types.ts +63 -14
- package/src/lib/subagent/workflow.ts +29 -2
- package/src/lib/thread/index.ts +5 -0
- package/src/lib/thread/keys.test.ts +101 -0
- package/src/lib/thread/keys.ts +94 -0
- package/src/lib/thread/manager.test.ts +139 -0
- package/src/lib/thread/manager.ts +105 -9
- package/src/lib/thread/proxy.ts +3 -0
- package/src/lib/thread/types.ts +64 -1
- package/src/lib/tool-router/index.ts +2 -0
- package/src/lib/tool-router/router-edge-cases.integration.test.ts +92 -0
- package/src/lib/tool-router/router.integration.test.ts +12 -0
- package/src/lib/tool-router/router.ts +89 -16
- package/src/lib/tool-router/types.ts +42 -1
- package/src/lib/types.ts +12 -0
- package/src/workflow.ts +14 -1
- package/tsup.config.ts +1 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
|
+
import type { ChildWorkflowOptions } from "@temporalio/workflow";
|
|
2
3
|
import type { JsonValue } from "../state/types";
|
|
3
4
|
import type {
|
|
4
5
|
ToolHandlerResponse,
|
|
@@ -12,22 +13,27 @@ import type {
|
|
|
12
13
|
} from "../lifecycle";
|
|
13
14
|
import type { SandboxOps, SandboxSnapshot } from "../sandbox/types";
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Subset of {@link ChildWorkflowOptions} that callers may override when a
|
|
18
|
+
* subagent is invoked. `workflowId`, `taskQueue`, and `args` are managed by
|
|
19
|
+
* the subagent handler itself and therefore cannot be set here.
|
|
20
|
+
*
|
|
21
|
+
* Configuring `workflowRunTimeout` (or `workflowExecutionTimeout`) is strongly
|
|
22
|
+
* recommended: it is the only reliable way to guarantee that a child workflow
|
|
23
|
+
* which fails during initialization or repeatedly fails workflow tasks will
|
|
24
|
+
* eventually be terminated, allowing the parent's `Subagent` tool call to fail
|
|
25
|
+
* deterministically instead of hanging forever waiting for a result.
|
|
26
|
+
*/
|
|
27
|
+
export type SubagentChildWorkflowOptions = Omit<
|
|
28
|
+
ChildWorkflowOptions,
|
|
29
|
+
"workflowId" | "taskQueue" | "args"
|
|
30
|
+
>;
|
|
31
|
+
|
|
15
32
|
/** ToolHandlerResponse with threadId required (subagents must always surface their thread) */
|
|
16
33
|
export type SubagentHandlerResponse<
|
|
17
34
|
TResult = null,
|
|
18
35
|
TToolResponse = JsonValue,
|
|
19
|
-
> = ToolHandlerResponse<TResult, TToolResponse
|
|
20
|
-
threadId: string;
|
|
21
|
-
sandboxId?: string;
|
|
22
|
-
/** Snapshot captured on session exit when `sandboxShutdown === "snapshot"`. */
|
|
23
|
-
snapshot?: SandboxSnapshot;
|
|
24
|
-
/**
|
|
25
|
-
* Snapshot captured immediately after the sandbox was seeded (before the
|
|
26
|
-
* first agent turn) when `continuation === "snapshot"`. Only set on the
|
|
27
|
-
* first call that actually created the sandbox.
|
|
28
|
-
*/
|
|
29
|
-
baseSnapshot?: SandboxSnapshot;
|
|
30
|
-
};
|
|
36
|
+
> = ToolHandlerResponse<TResult, TToolResponse>;
|
|
31
37
|
|
|
32
38
|
/**
|
|
33
39
|
* Raw workflow input fields passed from parent to child workflow.
|
|
@@ -124,6 +130,24 @@ export interface SubagentConfig<TResult extends z.ZodType = z.ZodType> {
|
|
|
124
130
|
workflow: SubagentWorkflow<TResult>;
|
|
125
131
|
/** Optional task queue - defaults to parent's queue if not specified */
|
|
126
132
|
taskQueue?: string;
|
|
133
|
+
/**
|
|
134
|
+
* Optional child workflow options forwarded to `executeChild` when the
|
|
135
|
+
* subagent is spawned. Use this to configure timeouts, retry policies, or
|
|
136
|
+
* parent-close behavior for the child workflow.
|
|
137
|
+
*
|
|
138
|
+
* **Recommended:** configure a `workflowRunTimeout` (or
|
|
139
|
+
* `workflowExecutionTimeout`) so that a child workflow that fails to
|
|
140
|
+
* initialize — or repeatedly fails workflow tasks without ever reaching a
|
|
141
|
+
* terminal state — is eventually terminated by the Temporal server. Without
|
|
142
|
+
* such a timeout, the parent's `Subagent` tool call can hang indefinitely
|
|
143
|
+
* waiting for the child to finish. When Temporal terminates the child, the
|
|
144
|
+
* tool call fails with a structured `ChildWorkflowFailure` that the router's
|
|
145
|
+
* failure hooks can handle just like any other tool error.
|
|
146
|
+
*
|
|
147
|
+
* `workflowId`, `taskQueue`, and `args` are managed by the subagent handler
|
|
148
|
+
* and cannot be overridden here.
|
|
149
|
+
*/
|
|
150
|
+
workflowOptions?: SubagentChildWorkflowOptions;
|
|
127
151
|
/** Optional Zod schema to validate the child workflow's result. If omitted, result is passed through as-is. */
|
|
128
152
|
resultSchema?: TResult;
|
|
129
153
|
/** Optional context passed to the subagent — a static object or a function evaluated at invocation time */
|
|
@@ -214,6 +238,13 @@ export type SubagentFnResult<
|
|
|
214
238
|
export interface ChildSandboxReadySignalPayload {
|
|
215
239
|
childWorkflowId: string;
|
|
216
240
|
sandboxId: string;
|
|
241
|
+
/**
|
|
242
|
+
* Present only when the session captured a seed snapshot on this run
|
|
243
|
+
* (`continuation === "snapshot"` + fresh creation). Allows the parent to
|
|
244
|
+
* publish the reusable base snapshot to concurrent waiters without
|
|
245
|
+
* blocking on the child workflow's completion.
|
|
246
|
+
*/
|
|
247
|
+
baseSnapshot?: SandboxSnapshot;
|
|
217
248
|
}
|
|
218
249
|
|
|
219
250
|
/**
|
|
@@ -228,6 +259,24 @@ export interface SubagentSessionInput {
|
|
|
228
259
|
sandbox?: SandboxInit;
|
|
229
260
|
/** Sandbox shutdown policy (default: "destroy") */
|
|
230
261
|
sandboxShutdown?: SubagentSandboxShutdown;
|
|
231
|
-
/**
|
|
232
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Called by the session as soon as the sandbox is created, before the
|
|
264
|
+
* agent loop starts. `baseSnapshot` is populated only when the session
|
|
265
|
+
* captured a seed snapshot (fresh creation + `sandboxShutdown === "snapshot"`).
|
|
266
|
+
*/
|
|
267
|
+
onSandboxReady?: (args: {
|
|
268
|
+
sandboxId: string;
|
|
269
|
+
baseSnapshot?: SandboxSnapshot;
|
|
270
|
+
}) => void;
|
|
271
|
+
/**
|
|
272
|
+
* Called by the session right before `runSession` returns. Installed by
|
|
273
|
+
* `defineSubagentWorkflow` to capture sandbox outputs and auto-forward
|
|
274
|
+
* them to the subagent's final result so user code never has to thread
|
|
275
|
+
* `sandboxId` / `snapshot` manually.
|
|
276
|
+
*/
|
|
277
|
+
onSessionExit?: (result: {
|
|
278
|
+
sandboxId?: string;
|
|
279
|
+
snapshot?: SandboxSnapshot;
|
|
280
|
+
threadId: string;
|
|
281
|
+
}) => void;
|
|
233
282
|
}
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
SubagentSessionInput,
|
|
13
13
|
} from "./types";
|
|
14
14
|
import type { SubagentSandboxShutdown } from "../lifecycle";
|
|
15
|
+
import type { SandboxSnapshot } from "../sandbox/types";
|
|
15
16
|
import { childSandboxReadySignal } from "./signals";
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -50,6 +51,8 @@ import { childSandboxReadySignal } from "./signals";
|
|
|
50
51
|
* });
|
|
51
52
|
*
|
|
52
53
|
* const { finalMessage, threadId } = await session.runSession({ stateManager });
|
|
54
|
+
* // `sandboxId`, `snapshot`, and `baseSnapshot` are auto-forwarded
|
|
55
|
+
* // from the session — no need to thread them through manually.
|
|
53
56
|
* return { toolResponse: finalMessage ?? "No response", data: null, threadId };
|
|
54
57
|
* },
|
|
55
58
|
* );
|
|
@@ -125,23 +128,47 @@ export function defineSubagentWorkflow(
|
|
|
125
128
|
}
|
|
126
129
|
const parentHandle = getExternalWorkflowHandle(parent.workflowId);
|
|
127
130
|
|
|
131
|
+
let capturedSandboxId: string | undefined;
|
|
132
|
+
let capturedSnapshot: SandboxSnapshot | undefined;
|
|
133
|
+
let capturedBaseSnapshot: SandboxSnapshot | undefined;
|
|
134
|
+
let capturedThreadId: string | undefined;
|
|
128
135
|
const sessionInput: SubagentSessionInput = {
|
|
129
136
|
agentName: config.name,
|
|
130
137
|
sandboxShutdown: effectiveShutdown,
|
|
131
138
|
...(workflowInput.thread && { thread: workflowInput.thread }),
|
|
132
139
|
...(workflowInput.sandbox && { sandbox: workflowInput.sandbox }),
|
|
133
|
-
onSandboxReady: (sandboxId
|
|
140
|
+
onSandboxReady: ({ sandboxId, baseSnapshot }) => {
|
|
141
|
+
capturedBaseSnapshot = baseSnapshot;
|
|
134
142
|
const isReuse = workflowInput.sandbox?.mode === "continue";
|
|
135
143
|
if (!isReuse) {
|
|
136
144
|
void parentHandle.signal(childSandboxReadySignal, {
|
|
137
145
|
childWorkflowId: workflowInfo().workflowId,
|
|
138
146
|
sandboxId,
|
|
147
|
+
...(baseSnapshot && { baseSnapshot }),
|
|
139
148
|
});
|
|
140
149
|
}
|
|
141
150
|
},
|
|
151
|
+
onSessionExit: ({ sandboxId, snapshot, threadId }) => {
|
|
152
|
+
capturedSandboxId = sandboxId;
|
|
153
|
+
capturedSnapshot = snapshot;
|
|
154
|
+
capturedThreadId = threadId;
|
|
155
|
+
},
|
|
142
156
|
};
|
|
143
157
|
|
|
144
|
-
|
|
158
|
+
const result = await fn(prompt, sessionInput, context ?? {});
|
|
159
|
+
|
|
160
|
+
// Auto-forward sandbox outputs captured from the session so user code
|
|
161
|
+
// never has to thread them through manually. Explicit values on the fn
|
|
162
|
+
// result take precedence.
|
|
163
|
+
return {
|
|
164
|
+
...result,
|
|
165
|
+
...(capturedThreadId !== undefined && { threadId: capturedThreadId }),
|
|
166
|
+
...(capturedSandboxId !== undefined && { sandboxId: capturedSandboxId }),
|
|
167
|
+
...(capturedSnapshot !== undefined && { snapshot: capturedSnapshot }),
|
|
168
|
+
...(capturedBaseSnapshot !== undefined && {
|
|
169
|
+
baseSnapshot: capturedBaseSnapshot,
|
|
170
|
+
}),
|
|
171
|
+
};
|
|
145
172
|
};
|
|
146
173
|
|
|
147
174
|
// for temporal workflow name
|
package/src/lib/thread/index.ts
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getThreadListKey,
|
|
4
|
+
getThreadMetaKey,
|
|
5
|
+
THREAD_TTL_SECONDS,
|
|
6
|
+
} from "./keys";
|
|
7
|
+
import { createThreadManager } from "./manager";
|
|
8
|
+
|
|
9
|
+
describe("thread keys (public helpers)", () => {
|
|
10
|
+
it("getThreadListKey matches the internal list-key format", () => {
|
|
11
|
+
expect(getThreadListKey("messages", "abc")).toBe("messages:thread:abc");
|
|
12
|
+
expect(getThreadListKey("myScope", "xyz")).toBe("myScope:thread:xyz");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("getThreadMetaKey matches the internal meta-key format", () => {
|
|
16
|
+
expect(getThreadMetaKey("messages", "abc")).toBe(
|
|
17
|
+
"messages:meta:thread:abc"
|
|
18
|
+
);
|
|
19
|
+
expect(getThreadMetaKey("myScope", "xyz")).toBe("myScope:meta:thread:xyz");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("THREAD_TTL_SECONDS is 90 days", () => {
|
|
23
|
+
expect(THREAD_TTL_SECONDS).toBe(60 * 60 * 24 * 90);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("createThreadManager ↔ public key helpers round-trip", () => {
|
|
28
|
+
it("writes and reads via the exact keys returned by the public helpers", async () => {
|
|
29
|
+
const store = new Map<string, string[]>();
|
|
30
|
+
const meta = new Map<string, string>();
|
|
31
|
+
|
|
32
|
+
const writtenListExpires = new Map<string, number>();
|
|
33
|
+
const writtenMetaExpires = new Map<string, number>();
|
|
34
|
+
|
|
35
|
+
const redis = {
|
|
36
|
+
exists: vi.fn(async (k: string) => (meta.has(k) ? 1 : 0)),
|
|
37
|
+
set: vi.fn(
|
|
38
|
+
async (k: string, v: string, _ex: string, ttl: number) => {
|
|
39
|
+
meta.set(k, v);
|
|
40
|
+
writtenMetaExpires.set(k, ttl);
|
|
41
|
+
return "OK";
|
|
42
|
+
}
|
|
43
|
+
),
|
|
44
|
+
del: vi.fn(async (...keys: string[]) => {
|
|
45
|
+
let n = 0;
|
|
46
|
+
for (const k of keys) {
|
|
47
|
+
if (store.delete(k)) n++;
|
|
48
|
+
if (meta.delete(k)) n++;
|
|
49
|
+
}
|
|
50
|
+
return n;
|
|
51
|
+
}),
|
|
52
|
+
rpush: vi.fn(async (k: string, ...values: string[]) => {
|
|
53
|
+
const list = store.get(k) ?? [];
|
|
54
|
+
list.push(...values);
|
|
55
|
+
store.set(k, list);
|
|
56
|
+
return list.length;
|
|
57
|
+
}),
|
|
58
|
+
lrange: vi.fn(async (k: string) => store.get(k) ?? []),
|
|
59
|
+
llen: vi.fn(async (k: string) => (store.get(k) ?? []).length),
|
|
60
|
+
ltrim: vi.fn(async () => "OK"),
|
|
61
|
+
expire: vi.fn(async (k: string, ttl: number) => {
|
|
62
|
+
if (store.has(k)) writtenListExpires.set(k, ttl);
|
|
63
|
+
if (meta.has(k)) writtenMetaExpires.set(k, ttl);
|
|
64
|
+
return 1;
|
|
65
|
+
}),
|
|
66
|
+
eval: vi.fn(async () => 1),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const threadKey = "messages";
|
|
70
|
+
const threadId = "t1";
|
|
71
|
+
const tm = createThreadManager<{ id: string }>({
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
73
|
+
redis: redis as any,
|
|
74
|
+
threadId,
|
|
75
|
+
key: threadKey,
|
|
76
|
+
idOf: (m) => m.id,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await tm.initialize();
|
|
80
|
+
|
|
81
|
+
const expectedList = getThreadListKey(threadKey, threadId);
|
|
82
|
+
const expectedMeta = getThreadMetaKey(threadKey, threadId);
|
|
83
|
+
|
|
84
|
+
expect(meta.has(expectedMeta)).toBe(true);
|
|
85
|
+
expect(writtenMetaExpires.get(expectedMeta)).toBe(THREAD_TTL_SECONDS);
|
|
86
|
+
|
|
87
|
+
// Append uses rpush (idOf set → eval path; bypass eval for this check by
|
|
88
|
+
// calling rpush path via no-idOf manager)
|
|
89
|
+
const tm2 = createThreadManager<{ id: string }>({
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
91
|
+
redis: redis as any,
|
|
92
|
+
threadId,
|
|
93
|
+
key: threadKey,
|
|
94
|
+
});
|
|
95
|
+
await tm2.append([{ id: "m1" }]);
|
|
96
|
+
|
|
97
|
+
expect(store.has(expectedList)).toBe(true);
|
|
98
|
+
expect(store.get(expectedList)).toEqual([JSON.stringify({ id: "m1" })]);
|
|
99
|
+
expect(writtenListExpires.get(expectedList)).toBe(THREAD_TTL_SECONDS);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public helpers for zeitlich's Redis thread storage layout.
|
|
3
|
+
*
|
|
4
|
+
* These are the exact string-building primitives zeitlich's internal thread
|
|
5
|
+
* manager uses for every adapter. Downstream consumers that need to read a
|
|
6
|
+
* persisted thread (for evaluation, observability, admin tooling, etc.)
|
|
7
|
+
* should use these helpers rather than reconstructing the key layout by
|
|
8
|
+
* hand — the layout is versioned with this module, so upgrading zeitlich
|
|
9
|
+
* keeps the consumer in sync.
|
|
10
|
+
*
|
|
11
|
+
* The layout is adapter-agnostic: every thread adapter stores messages the
|
|
12
|
+
* same way.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* import {
|
|
17
|
+
* getThreadListKey,
|
|
18
|
+
* getThreadMetaKey,
|
|
19
|
+
* THREAD_TTL_SECONDS,
|
|
20
|
+
* } from 'zeitlich';
|
|
21
|
+
*
|
|
22
|
+
* const listKey = getThreadListKey('messages', threadId);
|
|
23
|
+
* const metaKey = getThreadMetaKey('messages', threadId);
|
|
24
|
+
* const ttl = await redis.ttl(listKey); // <= THREAD_TTL_SECONDS
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* TTL (in seconds) applied to every thread list and thread meta key that
|
|
30
|
+
* zeitlich's {@link createThreadManager} writes. Exposed so downstream
|
|
31
|
+
* consumers can size their Redis retention / query windows to match.
|
|
32
|
+
*
|
|
33
|
+
* Current value: 90 days.
|
|
34
|
+
*/
|
|
35
|
+
export const THREAD_TTL_SECONDS = 60 * 60 * 24 * 90;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the Redis list key that holds a thread's serialized messages.
|
|
39
|
+
*
|
|
40
|
+
* Mirrors the exact key used internally by zeitlich's thread manager,
|
|
41
|
+
* so a consumer calling `redis.lrange(getThreadListKey(key, id), 0, -1)`
|
|
42
|
+
* sees the same data the writer wrote.
|
|
43
|
+
*
|
|
44
|
+
* @param threadKey - Thread key (defaults to `"messages"` inside the
|
|
45
|
+
* thread manager, but downstream adapters may pass
|
|
46
|
+
* their own value).
|
|
47
|
+
* @param threadId - Thread id as provided to the thread manager.
|
|
48
|
+
*/
|
|
49
|
+
export function getThreadListKey(
|
|
50
|
+
threadKey: string,
|
|
51
|
+
threadId: string
|
|
52
|
+
): string {
|
|
53
|
+
return `${threadKey}:thread:${threadId}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the Redis key that stores a thread's existence marker / metadata.
|
|
58
|
+
*
|
|
59
|
+
* Zeitlich treats the presence of this key as "thread has been
|
|
60
|
+
* initialized"; append/load/fork/truncate operations fail when it is
|
|
61
|
+
* missing. Consumers can use it as a cheap existence probe without
|
|
62
|
+
* scanning the message list.
|
|
63
|
+
*
|
|
64
|
+
* @param threadKey - Thread key (defaults to `"messages"` inside the
|
|
65
|
+
* thread manager, but downstream adapters may pass
|
|
66
|
+
* their own value).
|
|
67
|
+
* @param threadId - Thread id as provided to the thread manager.
|
|
68
|
+
*/
|
|
69
|
+
export function getThreadMetaKey(
|
|
70
|
+
threadKey: string,
|
|
71
|
+
threadId: string
|
|
72
|
+
): string {
|
|
73
|
+
return `${threadKey}:meta:thread:${threadId}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build the Redis key that stores a thread's persisted state slice
|
|
78
|
+
* (tasks + custom state) written by zeitlich's session loop on every
|
|
79
|
+
* exit path.
|
|
80
|
+
*
|
|
81
|
+
* Consumers can read this key with `redis.get(getThreadStateKey(key, id))`
|
|
82
|
+
* and `JSON.parse` the result into a {@link PersistedThreadState}.
|
|
83
|
+
*
|
|
84
|
+
* @param threadKey - Thread key (defaults to `"messages"` inside the
|
|
85
|
+
* thread manager, but downstream adapters may pass
|
|
86
|
+
* their own value).
|
|
87
|
+
* @param threadId - Thread id as provided to the thread manager.
|
|
88
|
+
*/
|
|
89
|
+
export function getThreadStateKey(
|
|
90
|
+
threadKey: string,
|
|
91
|
+
threadId: string
|
|
92
|
+
): string {
|
|
93
|
+
return `${threadKey}:state:thread:${threadId}`;
|
|
94
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
+
import type Redis from "ioredis";
|
|
3
|
+
import { createThreadManager } from "./manager";
|
|
4
|
+
import type { PersistedThreadState } from "../state/types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Minimal in-memory Redis stub exposing just the commands used by
|
|
8
|
+
* `createThreadManager`'s state methods (get/set/del/exists/expire) plus
|
|
9
|
+
* the list helpers needed for `initialize`.
|
|
10
|
+
*/
|
|
11
|
+
function createFakeRedis(): Redis {
|
|
12
|
+
const store = new Map<string, string>();
|
|
13
|
+
|
|
14
|
+
const redis = {
|
|
15
|
+
async get(key: string): Promise<string | null> {
|
|
16
|
+
return store.has(key) ? (store.get(key) as string) : null;
|
|
17
|
+
},
|
|
18
|
+
async set(key: string, value: string): Promise<"OK"> {
|
|
19
|
+
store.set(key, String(value));
|
|
20
|
+
return "OK";
|
|
21
|
+
},
|
|
22
|
+
async del(...keys: string[]): Promise<number> {
|
|
23
|
+
let removed = 0;
|
|
24
|
+
for (const k of keys) {
|
|
25
|
+
if (store.delete(k)) removed++;
|
|
26
|
+
}
|
|
27
|
+
return removed;
|
|
28
|
+
},
|
|
29
|
+
async exists(...keys: string[]): Promise<number> {
|
|
30
|
+
return keys.reduce((acc, k) => acc + (store.has(k) ? 1 : 0), 0);
|
|
31
|
+
},
|
|
32
|
+
async expire(_key: string, _ttl: number): Promise<number> {
|
|
33
|
+
return 1;
|
|
34
|
+
},
|
|
35
|
+
async lrange(): Promise<string[]> {
|
|
36
|
+
return [];
|
|
37
|
+
},
|
|
38
|
+
async rpush(): Promise<number> {
|
|
39
|
+
return 0;
|
|
40
|
+
},
|
|
41
|
+
_store: store,
|
|
42
|
+
} as unknown as Redis & { _store: Map<string, string> };
|
|
43
|
+
|
|
44
|
+
return redis;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const baseSlice: PersistedThreadState = {
|
|
48
|
+
tasks: [
|
|
49
|
+
[
|
|
50
|
+
"t1",
|
|
51
|
+
{
|
|
52
|
+
id: "t1",
|
|
53
|
+
subject: "do a thing",
|
|
54
|
+
description: "do it",
|
|
55
|
+
activeForm: "doing a thing",
|
|
56
|
+
status: "pending",
|
|
57
|
+
metadata: {},
|
|
58
|
+
blockedBy: [],
|
|
59
|
+
blocks: [],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
],
|
|
63
|
+
custom: { counter: 7, label: "hello" },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
describe("createThreadManager state persistence", () => {
|
|
67
|
+
let redis: Redis & { _store: Map<string, string> };
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
redis = createFakeRedis() as Redis & { _store: Map<string, string> };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
async function initThread(threadId: string): Promise<void> {
|
|
74
|
+
const tm = createThreadManager({ redis, threadId });
|
|
75
|
+
await tm.initialize();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
it("loadState returns null when nothing has been saved", async () => {
|
|
79
|
+
await initThread("thread-1");
|
|
80
|
+
const tm = createThreadManager({ redis, threadId: "thread-1" });
|
|
81
|
+
expect(await tm.loadState()).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("saveState writes a round-trippable JSON slice", async () => {
|
|
85
|
+
await initThread("thread-1");
|
|
86
|
+
const tm = createThreadManager({ redis, threadId: "thread-1" });
|
|
87
|
+
await tm.saveState(baseSlice);
|
|
88
|
+
|
|
89
|
+
const loaded = await tm.loadState();
|
|
90
|
+
expect(loaded).toEqual(baseSlice);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("saveState throws if the thread was never initialized", async () => {
|
|
94
|
+
const tm = createThreadManager({ redis, threadId: "missing" });
|
|
95
|
+
await expect(tm.saveState(baseSlice)).rejects.toThrow(/does not exist/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("fork copies the persisted state slice to the new thread", async () => {
|
|
99
|
+
await initThread("source");
|
|
100
|
+
const src = createThreadManager({ redis, threadId: "source" });
|
|
101
|
+
await src.saveState(baseSlice);
|
|
102
|
+
|
|
103
|
+
const dst = await src.fork("target");
|
|
104
|
+
|
|
105
|
+
expect(await dst.loadState()).toEqual(baseSlice);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("fork leaves the new thread's slice null when source has none", async () => {
|
|
109
|
+
await initThread("source");
|
|
110
|
+
const src = createThreadManager({ redis, threadId: "source" });
|
|
111
|
+
|
|
112
|
+
const dst = await src.fork("target");
|
|
113
|
+
|
|
114
|
+
expect(await dst.loadState()).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("deleteState removes only the state key", async () => {
|
|
118
|
+
await initThread("thread-1");
|
|
119
|
+
const tm = createThreadManager({ redis, threadId: "thread-1" });
|
|
120
|
+
await tm.saveState(baseSlice);
|
|
121
|
+
|
|
122
|
+
await tm.deleteState();
|
|
123
|
+
|
|
124
|
+
expect(await tm.loadState()).toBeNull();
|
|
125
|
+
const keys = Array.from(redis._store.keys());
|
|
126
|
+
expect(keys.some((k) => k.includes(":meta:"))).toBe(true);
|
|
127
|
+
expect(keys.some((k) => k.includes(":state"))).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("delete removes messages + meta + state together", async () => {
|
|
131
|
+
await initThread("thread-1");
|
|
132
|
+
const tm = createThreadManager({ redis, threadId: "thread-1" });
|
|
133
|
+
await tm.saveState(baseSlice);
|
|
134
|
+
|
|
135
|
+
await tm.delete();
|
|
136
|
+
|
|
137
|
+
expect(redis._store.size).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import type { PersistedThreadState } from "../state/types";
|
|
1
2
|
import type { ThreadManagerConfig, BaseThreadManager } from "./types";
|
|
2
|
-
|
|
3
|
-
|
|
3
|
+
import {
|
|
4
|
+
THREAD_TTL_SECONDS,
|
|
5
|
+
getThreadListKey,
|
|
6
|
+
getThreadMetaKey,
|
|
7
|
+
getThreadStateKey,
|
|
8
|
+
} from "./keys";
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* Lua script for atomic idempotent append.
|
|
@@ -23,8 +28,8 @@ redis.call('SET', KEYS[1], '1', 'EX', tonumber(ARGV[1]))
|
|
|
23
28
|
return 1
|
|
24
29
|
`;
|
|
25
30
|
|
|
26
|
-
function
|
|
27
|
-
return
|
|
31
|
+
function getDedupKey(threadId: string, id: string): string {
|
|
32
|
+
return `dedup:${id}:thread:${threadId}`;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/**
|
|
@@ -42,8 +47,9 @@ export function createThreadManager<T>(
|
|
|
42
47
|
deserialize = (raw: string): T => JSON.parse(raw) as T,
|
|
43
48
|
idOf,
|
|
44
49
|
} = config;
|
|
45
|
-
const redisKey =
|
|
46
|
-
const metaKey =
|
|
50
|
+
const redisKey = getThreadListKey(key, threadId);
|
|
51
|
+
const metaKey = getThreadMetaKey(key, threadId);
|
|
52
|
+
const stateKey = getThreadStateKey(key, threadId);
|
|
47
53
|
|
|
48
54
|
async function assertThreadExists(): Promise<void> {
|
|
49
55
|
const exists = await redis.exists(metaKey);
|
|
@@ -70,7 +76,7 @@ export function createThreadManager<T>(
|
|
|
70
76
|
|
|
71
77
|
if (idOf) {
|
|
72
78
|
const dedupId = messages.map(idOf).join(":");
|
|
73
|
-
const dedupKey =
|
|
79
|
+
const dedupKey = getDedupKey(threadId, dedupId);
|
|
74
80
|
await redis.eval(
|
|
75
81
|
APPEND_IDEMPOTENT_SCRIPT,
|
|
76
82
|
2,
|
|
@@ -88,21 +94,111 @@ export function createThreadManager<T>(
|
|
|
88
94
|
async fork(newThreadId: string): Promise<BaseThreadManager<T>> {
|
|
89
95
|
await assertThreadExists();
|
|
90
96
|
const data = await redis.lrange(redisKey, 0, -1);
|
|
97
|
+
const stateRaw = await redis.get(stateKey);
|
|
91
98
|
const forked = createThreadManager({
|
|
92
99
|
...config,
|
|
93
100
|
threadId: newThreadId,
|
|
94
101
|
});
|
|
95
102
|
await forked.initialize();
|
|
96
103
|
if (data.length > 0) {
|
|
97
|
-
const newKey =
|
|
104
|
+
const newKey = getThreadListKey(key, newThreadId);
|
|
98
105
|
await redis.rpush(newKey, ...data);
|
|
99
106
|
await redis.expire(newKey, THREAD_TTL_SECONDS);
|
|
100
107
|
}
|
|
108
|
+
if (stateRaw != null) {
|
|
109
|
+
const newStateKey = getThreadStateKey(key, newThreadId);
|
|
110
|
+
await redis.set(newStateKey, stateRaw, "EX", THREAD_TTL_SECONDS);
|
|
111
|
+
}
|
|
101
112
|
return forked;
|
|
102
113
|
},
|
|
103
114
|
|
|
115
|
+
async replaceAll(messages: T[]): Promise<void> {
|
|
116
|
+
await assertThreadExists();
|
|
117
|
+
if (!idOf) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
"replaceAll requires the thread manager to be configured with `idOf`"
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const existing = await redis.lrange(redisKey, 0, -1);
|
|
123
|
+
const existingIds = existing
|
|
124
|
+
.map((raw) => idOf(deserialize(raw)))
|
|
125
|
+
.filter((id): id is string => typeof id === "string");
|
|
126
|
+
await redis.del(redisKey);
|
|
127
|
+
if (existingIds.length > 0) {
|
|
128
|
+
await redis.del(
|
|
129
|
+
...existingIds.map((id) => getDedupKey(threadId, id))
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (messages.length > 0) {
|
|
133
|
+
await redis.rpush(redisKey, ...messages.map(serialize));
|
|
134
|
+
await redis.expire(redisKey, THREAD_TTL_SECONDS);
|
|
135
|
+
}
|
|
136
|
+
await redis.expire(metaKey, THREAD_TTL_SECONDS);
|
|
137
|
+
},
|
|
138
|
+
|
|
104
139
|
async delete(): Promise<void> {
|
|
105
|
-
await redis.del(redisKey, metaKey);
|
|
140
|
+
await redis.del(redisKey, metaKey, stateKey);
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async loadState(): Promise<PersistedThreadState | null> {
|
|
144
|
+
const raw = await redis.get(stateKey);
|
|
145
|
+
if (raw == null) return null;
|
|
146
|
+
return JSON.parse(raw) as PersistedThreadState;
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async saveState(state: PersistedThreadState): Promise<void> {
|
|
150
|
+
await assertThreadExists();
|
|
151
|
+
await redis.set(
|
|
152
|
+
stateKey,
|
|
153
|
+
JSON.stringify(state),
|
|
154
|
+
"EX",
|
|
155
|
+
THREAD_TTL_SECONDS
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async deleteState(): Promise<void> {
|
|
160
|
+
await redis.del(stateKey);
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
async length(): Promise<number> {
|
|
164
|
+
await assertThreadExists();
|
|
165
|
+
return redis.llen(redisKey);
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
async truncateFromId(messageId: string): Promise<void> {
|
|
169
|
+
await assertThreadExists();
|
|
170
|
+
if (!idOf) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
"truncateFromId requires the thread manager to be configured with `idOf`"
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const data = await redis.lrange(redisKey, 0, -1);
|
|
176
|
+
let idx = -1;
|
|
177
|
+
const removedIds: string[] = [];
|
|
178
|
+
for (let i = 0; i < data.length; i++) {
|
|
179
|
+
const raw = data[i];
|
|
180
|
+
if (raw === undefined) continue;
|
|
181
|
+
const id = idOf(deserialize(raw));
|
|
182
|
+
if (idx === -1 && id === messageId) idx = i;
|
|
183
|
+
if (idx !== -1) removedIds.push(id);
|
|
184
|
+
}
|
|
185
|
+
if (idx === -1) return;
|
|
186
|
+
if (idx === 0) {
|
|
187
|
+
await redis.del(redisKey);
|
|
188
|
+
await redis.expire(metaKey, THREAD_TTL_SECONDS);
|
|
189
|
+
} else {
|
|
190
|
+
await redis.ltrim(redisKey, 0, idx - 1);
|
|
191
|
+
await redis.expire(redisKey, THREAD_TTL_SECONDS);
|
|
192
|
+
}
|
|
193
|
+
// Clear dedup markers for the removed messages so that a rewind
|
|
194
|
+
// retry which reuses the same ids (e.g. the same assistantId) can
|
|
195
|
+
// re-append without the idempotent-append Lua script treating it
|
|
196
|
+
// as a duplicate.
|
|
197
|
+
if (removedIds.length > 0) {
|
|
198
|
+
await redis.del(
|
|
199
|
+
...removedIds.map((id) => getDedupKey(threadId, id))
|
|
200
|
+
);
|
|
201
|
+
}
|
|
106
202
|
},
|
|
107
203
|
};
|
|
108
204
|
}
|
package/src/lib/thread/proxy.ts
CHANGED
|
@@ -53,5 +53,8 @@ export function createThreadOpsProxy(
|
|
|
53
53
|
appendAgentMessage: acts[p("appendAgentMessage")],
|
|
54
54
|
appendSystemMessage: acts[p("appendSystemMessage")],
|
|
55
55
|
forkThread: acts[p("forkThread")],
|
|
56
|
+
truncateThread: acts[p("truncateThread")],
|
|
57
|
+
loadThreadState: acts[p("loadThreadState")],
|
|
58
|
+
saveThreadState: acts[p("saveThreadState")],
|
|
56
59
|
} as ActivityInterfaceFor<ThreadOps>;
|
|
57
60
|
}
|