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.
Files changed (172) hide show
  1. package/README.md +18 -0
  2. package/dist/{activities-Bb-nAjwQ.d.ts → activities-Bmu7XnaG.d.ts} +4 -4
  3. package/dist/{activities-vkI4_3CC.d.cts → activities-ByBFLvm2.d.cts} +4 -4
  4. package/dist/adapter-id-BB-mmrts.d.cts +17 -0
  5. package/dist/adapter-id-BB-mmrts.d.ts +17 -0
  6. package/dist/adapter-id-CMwVrVqv.d.cts +17 -0
  7. package/dist/adapter-id-CMwVrVqv.d.ts +17 -0
  8. package/dist/adapter-id-CbY2zeSt.d.cts +17 -0
  9. package/dist/adapter-id-CbY2zeSt.d.ts +17 -0
  10. package/dist/adapters/sandbox/bedrock/index.cjs +3 -3
  11. package/dist/adapters/sandbox/bedrock/index.cjs.map +1 -1
  12. package/dist/adapters/sandbox/bedrock/index.d.cts +6 -6
  13. package/dist/adapters/sandbox/bedrock/index.d.ts +6 -6
  14. package/dist/adapters/sandbox/bedrock/index.js +3 -3
  15. package/dist/adapters/sandbox/bedrock/index.js.map +1 -1
  16. package/dist/adapters/sandbox/bedrock/workflow.d.cts +2 -2
  17. package/dist/adapters/sandbox/bedrock/workflow.d.ts +2 -2
  18. package/dist/adapters/sandbox/daytona/index.cjs +3 -3
  19. package/dist/adapters/sandbox/daytona/index.cjs.map +1 -1
  20. package/dist/adapters/sandbox/daytona/index.d.cts +4 -4
  21. package/dist/adapters/sandbox/daytona/index.d.ts +4 -4
  22. package/dist/adapters/sandbox/daytona/index.js +3 -3
  23. package/dist/adapters/sandbox/daytona/index.js.map +1 -1
  24. package/dist/adapters/sandbox/daytona/workflow.d.cts +1 -1
  25. package/dist/adapters/sandbox/daytona/workflow.d.ts +1 -1
  26. package/dist/adapters/sandbox/e2b/index.cjs +26 -14
  27. package/dist/adapters/sandbox/e2b/index.cjs.map +1 -1
  28. package/dist/adapters/sandbox/e2b/index.d.cts +24 -4
  29. package/dist/adapters/sandbox/e2b/index.d.ts +24 -4
  30. package/dist/adapters/sandbox/e2b/index.js +26 -14
  31. package/dist/adapters/sandbox/e2b/index.js.map +1 -1
  32. package/dist/adapters/sandbox/e2b/workflow.d.cts +1 -1
  33. package/dist/adapters/sandbox/e2b/workflow.d.ts +1 -1
  34. package/dist/adapters/sandbox/inmemory/index.cjs +3 -3
  35. package/dist/adapters/sandbox/inmemory/index.cjs.map +1 -1
  36. package/dist/adapters/sandbox/inmemory/index.d.cts +4 -4
  37. package/dist/adapters/sandbox/inmemory/index.d.ts +4 -4
  38. package/dist/adapters/sandbox/inmemory/index.js +3 -3
  39. package/dist/adapters/sandbox/inmemory/index.js.map +1 -1
  40. package/dist/adapters/sandbox/inmemory/workflow.d.cts +1 -1
  41. package/dist/adapters/sandbox/inmemory/workflow.d.ts +1 -1
  42. package/dist/adapters/thread/anthropic/index.cjs +150 -13
  43. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  44. package/dist/adapters/thread/anthropic/index.d.cts +9 -8
  45. package/dist/adapters/thread/anthropic/index.d.ts +9 -8
  46. package/dist/adapters/thread/anthropic/index.js +150 -14
  47. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  48. package/dist/adapters/thread/anthropic/workflow.cjs +9 -3
  49. package/dist/adapters/thread/anthropic/workflow.cjs.map +1 -1
  50. package/dist/adapters/thread/anthropic/workflow.d.cts +6 -5
  51. package/dist/adapters/thread/anthropic/workflow.d.ts +6 -5
  52. package/dist/adapters/thread/anthropic/workflow.js +9 -4
  53. package/dist/adapters/thread/anthropic/workflow.js.map +1 -1
  54. package/dist/adapters/thread/google-genai/index.cjs +154 -13
  55. package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
  56. package/dist/adapters/thread/google-genai/index.d.cts +6 -5
  57. package/dist/adapters/thread/google-genai/index.d.ts +6 -5
  58. package/dist/adapters/thread/google-genai/index.js +154 -14
  59. package/dist/adapters/thread/google-genai/index.js.map +1 -1
  60. package/dist/adapters/thread/google-genai/workflow.cjs +9 -3
  61. package/dist/adapters/thread/google-genai/workflow.cjs.map +1 -1
  62. package/dist/adapters/thread/google-genai/workflow.d.cts +6 -5
  63. package/dist/adapters/thread/google-genai/workflow.d.ts +6 -5
  64. package/dist/adapters/thread/google-genai/workflow.js +9 -4
  65. package/dist/adapters/thread/google-genai/workflow.js.map +1 -1
  66. package/dist/adapters/thread/index.cjs +16 -0
  67. package/dist/adapters/thread/index.cjs.map +1 -0
  68. package/dist/adapters/thread/index.d.cts +34 -0
  69. package/dist/adapters/thread/index.d.ts +34 -0
  70. package/dist/adapters/thread/index.js +12 -0
  71. package/dist/adapters/thread/index.js.map +1 -0
  72. package/dist/adapters/thread/langchain/index.cjs +149 -14
  73. package/dist/adapters/thread/langchain/index.cjs.map +1 -1
  74. package/dist/adapters/thread/langchain/index.d.cts +9 -8
  75. package/dist/adapters/thread/langchain/index.d.ts +9 -8
  76. package/dist/adapters/thread/langchain/index.js +149 -15
  77. package/dist/adapters/thread/langchain/index.js.map +1 -1
  78. package/dist/adapters/thread/langchain/workflow.cjs +9 -3
  79. package/dist/adapters/thread/langchain/workflow.cjs.map +1 -1
  80. package/dist/adapters/thread/langchain/workflow.d.cts +6 -5
  81. package/dist/adapters/thread/langchain/workflow.d.ts +6 -5
  82. package/dist/adapters/thread/langchain/workflow.js +9 -4
  83. package/dist/adapters/thread/langchain/workflow.js.map +1 -1
  84. package/dist/index.cjs +367 -59
  85. package/dist/index.cjs.map +1 -1
  86. package/dist/index.d.cts +11 -11
  87. package/dist/index.d.ts +11 -11
  88. package/dist/index.js +365 -61
  89. package/dist/index.js.map +1 -1
  90. package/dist/{proxy-DEtowJyd.d.cts → proxy-BAKzNGRq.d.cts} +1 -1
  91. package/dist/{proxy-0smGKvx8.d.ts → proxy-DO_MXbY4.d.ts} +1 -1
  92. package/dist/{thread-manager-C-C4pI2z.d.ts → thread-manager-CcRXasqs.d.ts} +2 -2
  93. package/dist/{thread-manager-D4vgzYrh.d.cts → thread-manager-ClwSaUnj.d.cts} +2 -2
  94. package/dist/{thread-manager-3fszQih4.d.ts → thread-manager-D-7lp1JK.d.ts} +2 -2
  95. package/dist/{thread-manager-CzYln2OC.d.cts → thread-manager-Y8Ucf0Tf.d.cts} +2 -2
  96. package/dist/{types-CPKDl-y_.d.ts → types-Bcbiq8iv.d.cts} +195 -22
  97. package/dist/{types-CNuWnvy9.d.ts → types-DAsQ21Rt.d.ts} +1 -1
  98. package/dist/{types-B37hKoWA.d.ts → types-DpHTX-iO.d.ts} +58 -1
  99. package/dist/{types-BO7Yju20.d.cts → types-Dt8-HBBT.d.ts} +195 -22
  100. package/dist/{types-D08CXPh8.d.cts → types-hFFi-Zd9.d.cts} +58 -1
  101. package/dist/{types-DWEUmYAJ.d.cts → types-lm8tMNJQ.d.cts} +1 -1
  102. package/dist/{types-tQL9njTu.d.cts → types-yx0LzPGn.d.cts} +21 -7
  103. package/dist/{types-tQL9njTu.d.ts → types-yx0LzPGn.d.ts} +21 -7
  104. package/dist/{workflow-CjXHbZZc.d.ts → workflow-Bmf9EtDW.d.ts} +83 -3
  105. package/dist/{workflow-Do_lzJpT.d.cts → workflow-Bx9utBwb.d.cts} +83 -3
  106. package/dist/workflow.cjs +266 -39
  107. package/dist/workflow.cjs.map +1 -1
  108. package/dist/workflow.d.cts +3 -3
  109. package/dist/workflow.d.ts +3 -3
  110. package/dist/workflow.js +264 -41
  111. package/dist/workflow.js.map +1 -1
  112. package/package.json +12 -2
  113. package/src/adapters/sandbox/bedrock/index.ts +12 -3
  114. package/src/adapters/sandbox/daytona/index.ts +12 -3
  115. package/src/adapters/sandbox/e2b/index.ts +36 -14
  116. package/src/adapters/sandbox/e2b/types.ts +16 -0
  117. package/src/adapters/sandbox/inmemory/index.ts +12 -3
  118. package/src/adapters/thread/adapter-id.test.ts +42 -0
  119. package/src/adapters/thread/anthropic/activities.ts +40 -5
  120. package/src/adapters/thread/anthropic/adapter-id.ts +16 -0
  121. package/src/adapters/thread/anthropic/fork-transform.test.ts +291 -0
  122. package/src/adapters/thread/anthropic/index.ts +3 -0
  123. package/src/adapters/thread/anthropic/model-invoker.ts +7 -1
  124. package/src/adapters/thread/anthropic/proxy.ts +3 -2
  125. package/src/adapters/thread/anthropic/thread-manager.ts +27 -1
  126. package/src/adapters/thread/google-genai/activities.ts +44 -5
  127. package/src/adapters/thread/google-genai/adapter-id.ts +16 -0
  128. package/src/adapters/thread/google-genai/fork-transform.test.ts +149 -0
  129. package/src/adapters/thread/google-genai/index.ts +3 -0
  130. package/src/adapters/thread/google-genai/model-invoker.ts +8 -2
  131. package/src/adapters/thread/google-genai/proxy.ts +3 -2
  132. package/src/adapters/thread/google-genai/thread-manager.ts +27 -1
  133. package/src/adapters/thread/index.ts +39 -0
  134. package/src/adapters/thread/langchain/activities.ts +40 -5
  135. package/src/adapters/thread/langchain/adapter-id.ts +16 -0
  136. package/src/adapters/thread/langchain/fork-transform.test.ts +142 -0
  137. package/src/adapters/thread/langchain/index.ts +3 -0
  138. package/src/adapters/thread/langchain/model-invoker.ts +7 -1
  139. package/src/adapters/thread/langchain/proxy.ts +3 -2
  140. package/src/adapters/thread/langchain/thread-manager.ts +27 -1
  141. package/src/lib/lifecycle.ts +14 -5
  142. package/src/lib/model/types.ts +7 -0
  143. package/src/lib/sandbox/manager.ts +26 -18
  144. package/src/lib/sandbox/types.ts +27 -7
  145. package/src/lib/session/session-edge-cases.integration.test.ts +336 -4
  146. package/src/lib/session/session.integration.test.ts +192 -2
  147. package/src/lib/session/session.ts +102 -8
  148. package/src/lib/session/types.ts +66 -3
  149. package/src/lib/state/index.ts +1 -0
  150. package/src/lib/state/manager.integration.test.ts +109 -0
  151. package/src/lib/state/manager.ts +38 -8
  152. package/src/lib/state/types.ts +25 -0
  153. package/src/lib/subagent/handler.ts +124 -11
  154. package/src/lib/subagent/index.ts +5 -1
  155. package/src/lib/subagent/subagent.integration.test.ts +628 -104
  156. package/src/lib/subagent/types.ts +63 -14
  157. package/src/lib/subagent/workflow.ts +29 -2
  158. package/src/lib/thread/index.ts +5 -0
  159. package/src/lib/thread/keys.test.ts +101 -0
  160. package/src/lib/thread/keys.ts +94 -0
  161. package/src/lib/thread/manager.test.ts +139 -0
  162. package/src/lib/thread/manager.ts +105 -9
  163. package/src/lib/thread/proxy.ts +3 -0
  164. package/src/lib/thread/types.ts +64 -1
  165. package/src/lib/tool-router/index.ts +2 -0
  166. package/src/lib/tool-router/router-edge-cases.integration.test.ts +92 -0
  167. package/src/lib/tool-router/router.integration.test.ts +12 -0
  168. package/src/lib/tool-router/router.ts +89 -16
  169. package/src/lib/tool-router/types.ts +42 -1
  170. package/src/lib/types.ts +12 -0
  171. package/src/workflow.ts +14 -1
  172. 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
- /** Called by the session as soon as the sandbox is created, before the agent loop starts. */
232
- onSandboxReady?: (sandboxId: string) => void;
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: string) => {
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
- return fn(prompt, sessionInput, context ?? {});
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
@@ -1,5 +1,10 @@
1
1
  export { createThreadManager } from "./manager";
2
2
  export { createThreadOpsProxy } from "./proxy";
3
+ export {
4
+ THREAD_TTL_SECONDS,
5
+ getThreadListKey,
6
+ getThreadMetaKey,
7
+ } from "./keys";
3
8
 
4
9
  export type {
5
10
  ThreadManagerConfig,
@@ -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
- const THREAD_TTL_SECONDS = 60 * 60 * 24 * 90; // 90 days
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 getThreadKey(threadId: string, key: string): string {
27
- return `${key}:thread:${threadId}`;
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 = getThreadKey(threadId, key);
46
- const metaKey = getThreadKey(threadId, `${key}:meta`);
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 = getThreadKey(threadId, `dedup:${dedupId}`);
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 = getThreadKey(newThreadId, key);
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
  }
@@ -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
  }