@stigmer/runner 3.1.7 → 3.1.9

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 (110) hide show
  1. package/README.md +1 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/discover-mcp-server.js +1 -0
  4. package/dist/activities/discover-mcp-server.js.map +1 -1
  5. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -2
  6. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  7. package/dist/activities/execute-cursor/approval-state.d.ts +58 -10
  8. package/dist/activities/execute-cursor/approval-state.js +37 -8
  9. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  10. package/dist/activities/execute-cursor/env-resolver.js +7 -1
  11. package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
  12. package/dist/activities/execute-cursor/hook-script.d.ts +16 -3
  13. package/dist/activities/execute-cursor/hook-script.js +66 -21
  14. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  15. package/dist/activities/execute-cursor/index.js +59 -0
  16. package/dist/activities/execute-cursor/index.js.map +1 -1
  17. package/dist/activities/execute-cursor/message-translator.d.ts +52 -0
  18. package/dist/activities/execute-cursor/message-translator.js +107 -1
  19. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  20. package/dist/activities/execute-cursor/turn-boundary.d.ts +25 -3
  21. package/dist/activities/execute-cursor/turn-boundary.js +46 -8
  22. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
  23. package/dist/activities/execute-cursor/turn-stream.js +6 -2
  24. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  25. package/dist/activities/execute-cursor/workspace-setup.d.ts +16 -2
  26. package/dist/activities/execute-cursor/workspace-setup.js +22 -12
  27. package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/environment.js +7 -1
  29. package/dist/activities/execute-deep-agent/environment.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/hitl.d.ts +23 -14
  31. package/dist/activities/execute-deep-agent/hitl.js +42 -37
  32. package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +35 -26
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/setup.d.ts +7 -0
  36. package/dist/activities/execute-deep-agent/setup.js +9 -2
  37. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  38. package/dist/activities/hydrate-workflow-execution.js +8 -1
  39. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  40. package/dist/client/stigmer-client.d.ts +69 -1
  41. package/dist/client/stigmer-client.js +108 -4
  42. package/dist/client/stigmer-client.js.map +1 -1
  43. package/dist/client/token-claims.d.ts +22 -0
  44. package/dist/client/token-claims.js +40 -0
  45. package/dist/client/token-claims.js.map +1 -0
  46. package/dist/config.d.ts +11 -1
  47. package/dist/config.js +5 -1
  48. package/dist/config.js.map +1 -1
  49. package/dist/middleware/approval-gate.js +9 -2
  50. package/dist/middleware/approval-gate.js.map +1 -1
  51. package/dist/runner-manager.d.ts +4 -2
  52. package/dist/runner-manager.js +20 -7
  53. package/dist/runner-manager.js.map +1 -1
  54. package/dist/runner.d.ts +2 -2
  55. package/dist/runner.js +1 -1
  56. package/dist/shared/checkpointer/factory.d.ts +11 -7
  57. package/dist/shared/checkpointer/factory.js +21 -8
  58. package/dist/shared/checkpointer/factory.js.map +1 -1
  59. package/dist/shared/checkpointer/sqlite-saver.d.ts +54 -0
  60. package/dist/shared/checkpointer/sqlite-saver.js +272 -0
  61. package/dist/shared/checkpointer/sqlite-saver.js.map +1 -0
  62. package/dist/shared/checkpointer/types.d.ts +8 -4
  63. package/dist/shared/tool-row.js +14 -1
  64. package/dist/shared/tool-row.js.map +1 -1
  65. package/dist/shared/workspace/platform-dir.d.ts +18 -0
  66. package/dist/shared/workspace/platform-dir.js +23 -0
  67. package/dist/shared/workspace/platform-dir.js.map +1 -1
  68. package/package.json +3 -3
  69. package/src/__test-utils__/mock-client.ts +3 -0
  70. package/src/activities/__tests__/hydrate-workflow-execution.test.ts +2 -0
  71. package/src/activities/discover-mcp-server.ts +1 -0
  72. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +5 -3
  73. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +272 -14
  74. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +72 -16
  75. package/src/activities/execute-cursor/__tests__/message-translator.test.ts +65 -0
  76. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +128 -1
  77. package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +95 -0
  78. package/src/activities/execute-cursor/approval-state.ts +75 -11
  79. package/src/activities/execute-cursor/env-resolver.ts +8 -1
  80. package/src/activities/execute-cursor/hook-script.ts +66 -21
  81. package/src/activities/execute-cursor/index.ts +61 -0
  82. package/src/activities/execute-cursor/message-translator.ts +124 -1
  83. package/src/activities/execute-cursor/turn-boundary.ts +79 -6
  84. package/src/activities/execute-cursor/turn-stream.ts +6 -2
  85. package/src/activities/execute-cursor/workspace-setup.ts +42 -14
  86. package/src/activities/execute-deep-agent/__tests__/environment.test.ts +5 -0
  87. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +314 -0
  88. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +64 -46
  89. package/src/activities/execute-deep-agent/environment.ts +8 -1
  90. package/src/activities/execute-deep-agent/hitl.ts +43 -45
  91. package/src/activities/execute-deep-agent/index.ts +36 -27
  92. package/src/activities/execute-deep-agent/setup.ts +15 -2
  93. package/src/activities/hydrate-workflow-execution.ts +9 -1
  94. package/src/client/__tests__/stigmer-client.test.ts +239 -2
  95. package/src/client/__tests__/token-claims.test.ts +45 -0
  96. package/src/client/stigmer-client.ts +152 -3
  97. package/src/client/token-claims.ts +42 -0
  98. package/src/config.ts +16 -3
  99. package/src/middleware/__tests__/approval-gate.test.ts +8 -1
  100. package/src/middleware/approval-gate.ts +9 -2
  101. package/src/runner-manager.ts +22 -8
  102. package/src/runner.ts +3 -3
  103. package/src/shared/__tests__/tool-row.test.ts +35 -0
  104. package/src/shared/checkpointer/__tests__/factory.test.ts +27 -1
  105. package/src/shared/checkpointer/__tests__/sqlite-saver.test.ts +212 -0
  106. package/src/shared/checkpointer/factory.ts +25 -8
  107. package/src/shared/checkpointer/sqlite-saver.ts +384 -0
  108. package/src/shared/checkpointer/types.ts +8 -4
  109. package/src/shared/tool-row.ts +14 -1
  110. package/src/shared/workspace/platform-dir.ts +25 -0
@@ -0,0 +1,212 @@
1
+ /**
2
+ * SqliteCheckpointSaver tests.
3
+ *
4
+ * The saver is the durable OSS/local checkpointer that makes HITL/pause/
5
+ * transient-recovery resume across ExecuteDeepAgent invocations (stigmer/stigmer#204).
6
+ * These tests pin two things:
7
+ * 1. Behavioral parity with the reference MemorySaver for the operations the
8
+ * resume path relies on (put/getTuple round-trip incl. pending writes).
9
+ * 2. The property MemorySaver cannot provide and that the whole feature rests
10
+ * on: durability across a close/reopen of the file (i.e. across the
11
+ * per-invocation open/close the activity performs).
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import {
19
+ MemorySaver,
20
+ emptyCheckpoint,
21
+ WRITES_IDX_MAP,
22
+ type BaseCheckpointSaver,
23
+ type Checkpoint,
24
+ type CheckpointMetadata,
25
+ type PendingWrite,
26
+ } from "@langchain/langgraph-checkpoint";
27
+ import type { RunnableConfig } from "@langchain/core/runnables";
28
+ import { SqliteCheckpointSaver } from "../sqlite-saver.js";
29
+
30
+ const THREAD = "thread-abc";
31
+
32
+ function threadConfig(checkpointId?: string): RunnableConfig {
33
+ return {
34
+ configurable: {
35
+ thread_id: THREAD,
36
+ checkpoint_ns: "",
37
+ ...(checkpointId ? { checkpoint_id: checkpointId } : {}),
38
+ },
39
+ };
40
+ }
41
+
42
+ function makeCheckpoint(id: string, values: Record<string, unknown> = {}): Checkpoint {
43
+ return { ...emptyCheckpoint(), id, channel_values: values };
44
+ }
45
+
46
+ function meta(source: CheckpointMetadata["source"], step: number): CheckpointMetadata {
47
+ return { source, step, parents: {} };
48
+ }
49
+
50
+ describe("SqliteCheckpointSaver", () => {
51
+ let dir: string;
52
+ let dbPath: string;
53
+ let saver: SqliteCheckpointSaver;
54
+
55
+ beforeEach(() => {
56
+ dir = mkdtempSync(join(tmpdir(), "stigmer-sqlite-cp-"));
57
+ dbPath = join(dir, "checkpoints.db");
58
+ saver = new SqliteCheckpointSaver(dbPath);
59
+ });
60
+
61
+ afterEach(() => {
62
+ saver.close();
63
+ rmSync(dir, { recursive: true, force: true });
64
+ });
65
+
66
+ it("round-trips a checkpoint and its metadata", async () => {
67
+ const cp = makeCheckpoint("cp-1", { messages: ["hello"] });
68
+ const returned = await saver.put(threadConfig(), cp, meta("input", 1), {});
69
+ expect(returned.configurable?.checkpoint_id).toBe("cp-1");
70
+
71
+ const tuple = await saver.getTuple(threadConfig("cp-1"));
72
+ expect(tuple).toBeDefined();
73
+ expect(tuple!.checkpoint.id).toBe("cp-1");
74
+ expect(tuple!.checkpoint.channel_values).toEqual({ messages: ["hello"] });
75
+ expect(tuple!.metadata).toEqual(meta("input", 1));
76
+ });
77
+
78
+ it("returns the latest checkpoint when no checkpoint_id is given", async () => {
79
+ await saver.put(threadConfig(), makeCheckpoint("cp-1"), meta("input", 1), {});
80
+ await saver.put(threadConfig("cp-1"), makeCheckpoint("cp-2"), meta("loop", 2), {});
81
+
82
+ const latest = await saver.getTuple(threadConfig());
83
+ expect(latest!.checkpoint.id).toBe("cp-2");
84
+ // The parent config points back to the previous checkpoint.
85
+ expect(latest!.parentConfig?.configurable?.checkpoint_id).toBe("cp-1");
86
+ });
87
+
88
+ it("returns undefined for an unknown thread", async () => {
89
+ const tuple = await saver.getTuple({
90
+ configurable: { thread_id: "nope", checkpoint_ns: "" },
91
+ });
92
+ expect(tuple).toBeUndefined();
93
+ });
94
+
95
+ it("hydrates pending writes on getTuple", async () => {
96
+ await saver.put(threadConfig(), makeCheckpoint("cp-1"), meta("input", 1), {});
97
+ const writes: PendingWrite[] = [
98
+ ["messages", { role: "assistant", content: "hi" }],
99
+ ["counter", 7],
100
+ ];
101
+ await saver.putWrites(threadConfig("cp-1"), writes, "task-1");
102
+
103
+ const tuple = await saver.getTuple(threadConfig("cp-1"));
104
+ expect(tuple!.pendingWrites).toEqual([
105
+ ["task-1", "messages", { role: "assistant", content: "hi" }],
106
+ ["task-1", "counter", 7],
107
+ ]);
108
+ });
109
+
110
+ it("REPLACEs special-channel writes but IGNOREs duplicate regular writes", async () => {
111
+ await saver.put(threadConfig(), makeCheckpoint("cp-1"), meta("input", 1), {});
112
+
113
+ // Special channels (fixed negative idx): a later write overwrites the earlier
114
+ // one at the same slot — this is what lets a RESUME overwrite an INTERRUPT.
115
+ const interruptCh = "__interrupt__";
116
+ expect(interruptCh in WRITES_IDX_MAP).toBe(true);
117
+ await saver.putWrites(threadConfig("cp-1"), [[interruptCh, "first"]], "task-1");
118
+ await saver.putWrites(threadConfig("cp-1"), [[interruptCh, "second"]], "task-1");
119
+
120
+ // Regular channels: the first write at (task_id, idx) wins; a duplicate is
121
+ // ignored so concurrent tasks can't clobber each other.
122
+ await saver.putWrites(threadConfig("cp-1"), [["messages", "original"]], "task-2");
123
+ await saver.putWrites(threadConfig("cp-1"), [["messages", "overwrite?"]], "task-2");
124
+
125
+ const tuple = await saver.getTuple(threadConfig("cp-1"));
126
+ const byChannel = new Map(
127
+ tuple!.pendingWrites!.map(([, channel, value]) => [channel, value]),
128
+ );
129
+ expect(byChannel.get(interruptCh)).toBe("second");
130
+ expect(byChannel.get("messages")).toBe("original");
131
+ });
132
+
133
+ it("lists checkpoints newest-first with limit and before", async () => {
134
+ for (const id of ["cp-1", "cp-2", "cp-3"]) {
135
+ await saver.put(threadConfig(), makeCheckpoint(id), meta("loop", 1), {});
136
+ }
137
+
138
+ const all: string[] = [];
139
+ for await (const t of saver.list(threadConfig())) all.push(t.checkpoint.id);
140
+ expect(all).toEqual(["cp-3", "cp-2", "cp-1"]);
141
+
142
+ const limited: string[] = [];
143
+ for await (const t of saver.list(threadConfig(), { limit: 2 })) {
144
+ limited.push(t.checkpoint.id);
145
+ }
146
+ expect(limited).toEqual(["cp-3", "cp-2"]);
147
+
148
+ const before: string[] = [];
149
+ for await (const t of saver.list(threadConfig(), {
150
+ before: { configurable: { checkpoint_id: "cp-3" } },
151
+ })) {
152
+ before.push(t.checkpoint.id);
153
+ }
154
+ expect(before).toEqual(["cp-2", "cp-1"]);
155
+ });
156
+
157
+ it("deleteThread removes checkpoints and writes", async () => {
158
+ await saver.put(threadConfig(), makeCheckpoint("cp-1"), meta("input", 1), {});
159
+ await saver.putWrites(threadConfig("cp-1"), [["messages", "x"]], "task-1");
160
+
161
+ await saver.deleteThread(THREAD);
162
+
163
+ expect(await saver.getTuple(threadConfig("cp-1"))).toBeUndefined();
164
+ expect(await saver.getTuple(threadConfig())).toBeUndefined();
165
+ });
166
+
167
+ it("persists across a close and reopen of the same file (durability)", async () => {
168
+ await saver.put(threadConfig(), makeCheckpoint("cp-1", { messages: ["kept"] }), meta("input", 1), {});
169
+ await saver.putWrites(threadConfig("cp-1"), [["messages", "pending"]], "task-1");
170
+ saver.close();
171
+
172
+ // A fresh saver on the same path — the shape of the activity's per-invocation
173
+ // open/close — must read what the prior invocation wrote.
174
+ const reopened = new SqliteCheckpointSaver(dbPath);
175
+ try {
176
+ const tuple = await reopened.getTuple(threadConfig("cp-1"));
177
+ expect(tuple!.checkpoint.channel_values).toEqual({ messages: ["kept"] });
178
+ expect(tuple!.pendingWrites).toEqual([["task-1", "messages", "pending"]]);
179
+ } finally {
180
+ reopened.close();
181
+ }
182
+ });
183
+
184
+ it("close() is idempotent", () => {
185
+ saver.close();
186
+ expect(() => saver.close()).not.toThrow();
187
+ });
188
+
189
+ it("matches MemorySaver on the put -> putWrites -> getTuple path (parity)", async () => {
190
+ const memory: BaseCheckpointSaver = new MemorySaver();
191
+
192
+ const run = async (s: BaseCheckpointSaver) => {
193
+ await s.put(threadConfig(), makeCheckpoint("cp-1", { step: "a" }), meta("input", 1), {});
194
+ await s.putWrites(
195
+ threadConfig("cp-1"),
196
+ [["messages", { role: "assistant", content: "parity" }]],
197
+ "task-1",
198
+ );
199
+ const tuple = await s.getTuple(threadConfig("cp-1"));
200
+ return {
201
+ id: tuple!.checkpoint.id,
202
+ values: tuple!.checkpoint.channel_values,
203
+ metadata: tuple!.metadata,
204
+ pendingWrites: tuple!.pendingWrites,
205
+ };
206
+ };
207
+
208
+ const sqliteResult = await run(saver);
209
+ const memoryResult = await run(memory);
210
+ expect(sqliteResult).toEqual(memoryResult);
211
+ });
212
+ });
@@ -2,9 +2,13 @@
2
2
  * Checkpointer factory — creates the appropriate LangGraph checkpoint
3
3
  * saver based on runner configuration.
4
4
  *
5
- * Two backends:
6
- * - memory: MemorySaver (ephemeral, zero-config) — for OSS / local mode
7
- * - http: HttpCheckpointSaver (proxy-backed) for cloud mode
5
+ * Three backends:
6
+ * - sqlite: SqliteCheckpointSaver (durable local file) — the OSS / local /
7
+ * desktop default. Survives across ExecuteDeepAgent invocations so HITL,
8
+ * pause/resume, and transient recovery resume via Command(resume) rather than
9
+ * replaying from the original message (stigmer/stigmer#204).
10
+ * - http: HttpCheckpointSaver (proxy-backed) — for cloud / managed runners.
11
+ * - memory: MemorySaver (ephemeral, zero-config) — explicit opt-in for tests.
8
12
  *
9
13
  * The returned saver is used by the deep agent activity (Phase 3).
10
14
  * Cursor executions do not use LangGraph checkpointers — they rely on
@@ -15,6 +19,7 @@ import { MemorySaver } from "@langchain/langgraph-checkpoint";
15
19
  import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint";
16
20
  import type { CheckpointerConfig } from "./types.js";
17
21
  import { HttpCheckpointSaver } from "./http-saver.js";
22
+ import { SqliteCheckpointSaver } from "./sqlite-saver.js";
18
23
 
19
24
  export class CheckpointerCreationError extends Error {
20
25
  readonly checkpointerType: string;
@@ -31,15 +36,27 @@ export class CheckpointerCreationError extends Error {
31
36
  /**
32
37
  * Create the appropriate checkpoint saver for the given config.
33
38
  *
34
- * Unlike the Python version (which returns an async context manager),
35
- * the JS MemorySaver and HttpCheckpointSaver do not require async
36
- * initialization or cleanup. This function is kept async to accommodate
37
- * future backends that might need setup (e.g. SQLite connection).
39
+ * This function is async to accommodate backends that need setup. The
40
+ * SqliteCheckpointSaver opens its file handle in its constructor and creates
41
+ * the schema lazily on first use; it must be closed by the caller (the deep
42
+ * agent activity closes it in its cleanup, alongside the MCP connection).
38
43
  */
39
44
  export async function createCheckpointer(
40
45
  config: CheckpointerConfig,
41
46
  ): Promise<BaseCheckpointSaver> {
42
47
  switch (config.type) {
48
+ case "sqlite": {
49
+ if (!config.sqlitePath) {
50
+ throw new CheckpointerCreationError(
51
+ "sqlite",
52
+ "sqlitePath is required for SQLite checkpointer",
53
+ );
54
+ }
55
+ const saver = new SqliteCheckpointSaver(config.sqlitePath);
56
+ console.log(`Created SqliteCheckpointSaver checkpointer (path=${config.sqlitePath})`);
57
+ return saver;
58
+ }
59
+
43
60
  case "memory":
44
61
  console.log("Created MemorySaver checkpointer (ephemeral, in-memory)");
45
62
  return new MemorySaver();
@@ -66,7 +83,7 @@ export async function createCheckpointer(
66
83
  const exhaustive: never = config.type;
67
84
  throw new CheckpointerCreationError(
68
85
  String(exhaustive),
69
- `Unknown checkpointer type. Valid types: memory, http`,
86
+ `Unknown checkpointer type. Valid types: sqlite, http, memory`,
70
87
  );
71
88
  }
72
89
  }
@@ -0,0 +1,384 @@
1
+ /**
2
+ * SQLite-backed LangGraph checkpoint saver — the durable checkpointer for the
3
+ * OSS / local edition.
4
+ *
5
+ * Why this exists: the OSS-local default used to be the ephemeral `MemorySaver`,
6
+ * recreated empty on every `ExecuteDeepAgent` invocation. The Temporal workflow
7
+ * re-invokes the activity with the SAME `thread_id` on HITL approval, pause/
8
+ * resume, and transient recovery — but with an empty checkpointer the graph has
9
+ * nothing to resume from, so it replays from the original user message instead
10
+ * of continuing after the interrupt. This saver makes the checkpoint survive
11
+ * across invocations on a single machine, so those re-invocations resume via
12
+ * `Command(resume)` exactly like the cloud (`http`) path (see stigmer/stigmer#204).
13
+ *
14
+ * Implementation notes:
15
+ * - Backed by Node's built-in `node:sqlite` (`DatabaseSync`). Zero external
16
+ * dependencies and bundles cleanly into the slim artifact — `node:`-prefixed
17
+ * builtins stay external to esbuild, unlike a native addon such as
18
+ * `better-sqlite3` (which the official `@langchain/langgraph-checkpoint-sqlite`
19
+ * depends on and which cannot be bundled).
20
+ * - Schema and the `put` / `putWrites` conflict-resolution semantics mirror the
21
+ * official `@langchain/langgraph-checkpoint-sqlite` saver, so a checkpoint
22
+ * written here is byte-compatible with the reference implementation.
23
+ * - Serialization uses the inherited `JsonPlusSerializer` (`this.serde`), exactly
24
+ * like {@link HttpCheckpointSaver}. Serialized payloads are stored as raw BLOBs
25
+ * and read back as `Uint8Array`, so binary (msgpack) payloads round-trip
26
+ * losslessly — this is why writes are read via a dedicated query rather than
27
+ * the reference saver's `CAST(value AS TEXT)` JSON aggregation.
28
+ * - WAL journaling plus a busy timeout make the file safe to open from more than
29
+ * one process at once (the CLI daemon and the desktop runner can share a HOME).
30
+ */
31
+
32
+ import { DatabaseSync, type StatementSync, type SQLInputValue } from "node:sqlite";
33
+ import type { RunnableConfig } from "@langchain/core/runnables";
34
+ import {
35
+ BaseCheckpointSaver,
36
+ WRITES_IDX_MAP,
37
+ copyCheckpoint,
38
+ type ChannelVersions,
39
+ type Checkpoint,
40
+ type CheckpointListOptions,
41
+ type CheckpointMetadata,
42
+ type CheckpointPendingWrite,
43
+ type CheckpointTuple,
44
+ type PendingWrite,
45
+ type SerializerProtocol,
46
+ } from "@langchain/langgraph-checkpoint";
47
+
48
+ /** Milliseconds SQLite waits on a locked database before erroring (WAL busy handler). */
49
+ const DEFAULT_BUSY_TIMEOUT_MS = 5_000;
50
+
51
+ interface CheckpointColumns {
52
+ thread_id: string;
53
+ checkpoint_ns: string;
54
+ checkpoint_id: string;
55
+ parent_checkpoint_id: string | null;
56
+ type: string | null;
57
+ checkpoint: Uint8Array;
58
+ metadata: Uint8Array;
59
+ }
60
+
61
+ interface WriteColumns {
62
+ task_id: string;
63
+ channel: string;
64
+ type: string | null;
65
+ value: Uint8Array;
66
+ }
67
+
68
+ export class SqliteCheckpointSaver extends BaseCheckpointSaver {
69
+ private readonly db: DatabaseSync;
70
+ private isSetup = false;
71
+
72
+ constructor(dbPath: string, serde?: SerializerProtocol) {
73
+ super(serde);
74
+ this.db = new DatabaseSync(dbPath, { timeout: DEFAULT_BUSY_TIMEOUT_MS });
75
+ }
76
+
77
+ /**
78
+ * Create the schema on first use. WAL + the constructor's busy timeout make
79
+ * concurrent readers/writers (across processes sharing the file) safe. The
80
+ * tables and their primary keys mirror the official
81
+ * `@langchain/langgraph-checkpoint-sqlite` schema.
82
+ */
83
+ private setup(): void {
84
+ if (this.isSetup) return;
85
+
86
+ this.db.exec("PRAGMA journal_mode=WAL");
87
+ this.db.exec(`
88
+ CREATE TABLE IF NOT EXISTS checkpoints (
89
+ thread_id TEXT NOT NULL,
90
+ checkpoint_ns TEXT NOT NULL DEFAULT '',
91
+ checkpoint_id TEXT NOT NULL,
92
+ parent_checkpoint_id TEXT,
93
+ type TEXT,
94
+ checkpoint BLOB,
95
+ metadata BLOB,
96
+ PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
97
+ );
98
+ `);
99
+ this.db.exec(`
100
+ CREATE TABLE IF NOT EXISTS writes (
101
+ thread_id TEXT NOT NULL,
102
+ checkpoint_ns TEXT NOT NULL DEFAULT '',
103
+ checkpoint_id TEXT NOT NULL,
104
+ task_id TEXT NOT NULL,
105
+ idx INTEGER NOT NULL,
106
+ channel TEXT NOT NULL,
107
+ type TEXT,
108
+ value BLOB,
109
+ PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
110
+ );
111
+ `);
112
+
113
+ this.isSetup = true;
114
+ }
115
+
116
+ async getTuple(config: RunnableConfig): Promise<CheckpointTuple | undefined> {
117
+ this.setup();
118
+
119
+ const threadId = config.configurable?.thread_id as string | undefined;
120
+ const checkpointNs = (config.configurable?.checkpoint_ns as string | undefined) ?? "";
121
+ const checkpointId = config.configurable?.checkpoint_id as string | undefined;
122
+
123
+ const row = (checkpointId
124
+ ? this.db
125
+ .prepare(
126
+ `SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata
127
+ FROM checkpoints
128
+ WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?`,
129
+ )
130
+ .get(threadId ?? null, checkpointNs, checkpointId)
131
+ : this.db
132
+ .prepare(
133
+ `SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata
134
+ FROM checkpoints
135
+ WHERE thread_id = ? AND checkpoint_ns = ?
136
+ ORDER BY checkpoint_id DESC LIMIT 1`,
137
+ )
138
+ .get(threadId ?? null, checkpointNs)) as unknown as CheckpointColumns | undefined;
139
+
140
+ if (row === undefined) return undefined;
141
+
142
+ return this.rowToTuple(row);
143
+ }
144
+
145
+ async *list(
146
+ config: RunnableConfig,
147
+ options?: CheckpointListOptions,
148
+ ): AsyncGenerator<CheckpointTuple> {
149
+ this.setup();
150
+
151
+ const threadId = config.configurable?.thread_id as string | undefined;
152
+ const checkpointNs = config.configurable?.checkpoint_ns as string | undefined;
153
+ const before = options?.before?.configurable?.checkpoint_id as string | undefined;
154
+ const limit = options?.limit;
155
+
156
+ // Supported filter subset mirrors HttpCheckpointSaver (thread_id /
157
+ // checkpoint_ns / before / limit). The runner's resume path reads via
158
+ // getState -> getTuple, so metadata filtering is not exercised here.
159
+ const where: string[] = [];
160
+ const args: SQLInputValue[] = [];
161
+ if (threadId !== undefined) {
162
+ where.push("thread_id = ?");
163
+ args.push(threadId);
164
+ }
165
+ if (checkpointNs !== undefined) {
166
+ where.push("checkpoint_ns = ?");
167
+ args.push(checkpointNs);
168
+ }
169
+ if (before !== undefined) {
170
+ where.push("checkpoint_id < ?");
171
+ args.push(before);
172
+ }
173
+
174
+ let sql =
175
+ `SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata
176
+ FROM checkpoints`;
177
+ if (where.length > 0) sql += ` WHERE ${where.join(" AND ")}`;
178
+ sql += " ORDER BY checkpoint_id DESC";
179
+ if (limit !== undefined) sql += ` LIMIT ${parseInt(String(limit), 10)}`;
180
+
181
+ const rows = this.db.prepare(sql).all(...args) as unknown as CheckpointColumns[];
182
+ for (const row of rows) {
183
+ yield await this.rowToTuple(row);
184
+ }
185
+ }
186
+
187
+ async put(
188
+ config: RunnableConfig,
189
+ checkpoint: Checkpoint,
190
+ metadata: CheckpointMetadata,
191
+ _newVersions: ChannelVersions,
192
+ ): Promise<RunnableConfig> {
193
+ this.setup();
194
+
195
+ const threadId = config.configurable?.thread_id as string | undefined;
196
+ if (!threadId) {
197
+ throw new Error(`Missing "thread_id" field in passed "config.configurable".`);
198
+ }
199
+ const checkpointNs = (config.configurable?.checkpoint_ns as string | undefined) ?? "";
200
+ const parentCheckpointId = config.configurable?.checkpoint_id as string | undefined;
201
+
202
+ // Defensive copy before serialization, matching the reference saver.
203
+ const [[checkpointType, serializedCheckpoint], [metadataType, serializedMetadata]] =
204
+ await Promise.all([
205
+ this.serde.dumpsTyped(copyCheckpoint(checkpoint)),
206
+ this.serde.dumpsTyped(metadata),
207
+ ]);
208
+
209
+ if (checkpointType !== metadataType) {
210
+ throw new Error("Failed to serialize checkpoint and metadata to the same type.");
211
+ }
212
+
213
+ this.db
214
+ .prepare(
215
+ `INSERT OR REPLACE INTO checkpoints
216
+ (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata)
217
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
218
+ )
219
+ .run(
220
+ threadId,
221
+ checkpointNs,
222
+ checkpoint.id,
223
+ parentCheckpointId ?? null,
224
+ checkpointType,
225
+ serializedCheckpoint,
226
+ serializedMetadata,
227
+ );
228
+
229
+ return {
230
+ configurable: {
231
+ thread_id: threadId,
232
+ checkpoint_ns: checkpointNs,
233
+ checkpoint_id: checkpoint.id,
234
+ },
235
+ };
236
+ }
237
+
238
+ async putWrites(
239
+ config: RunnableConfig,
240
+ writes: PendingWrite[],
241
+ taskId: string,
242
+ ): Promise<void> {
243
+ this.setup();
244
+
245
+ const threadId = config.configurable?.thread_id as string | undefined;
246
+ if (!threadId) throw new Error("Missing thread_id field in config.configurable.");
247
+ const checkpointId = config.configurable?.checkpoint_id as string | undefined;
248
+ if (!checkpointId) throw new Error("Missing checkpoint_id field in config.configurable.");
249
+ const checkpointNs = (config.configurable?.checkpoint_ns as string | undefined) ?? "";
250
+
251
+ // Conflict resolution matches the reference saver and the
252
+ // langgraph-checkpoint contract:
253
+ // - All-special writes (ERROR / SCHEDULED / INTERRUPT / RESUME, each pinned
254
+ // to a negative idx by WRITES_IDX_MAP) REPLACE, so e.g. INTERRUPT can be
255
+ // overwritten by RESUME.
256
+ // - Otherwise IGNORE, so a regular write from one task never clobbers a
257
+ // regular write another concurrent task already stored at the same
258
+ // (task_id, idx).
259
+ const allSpecial = writes.every(([channel]) => channel in WRITES_IDX_MAP);
260
+ const stmt: StatementSync = this.db.prepare(
261
+ `INSERT ${allSpecial ? "OR REPLACE" : "OR IGNORE"} INTO writes
262
+ (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value)
263
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
264
+ );
265
+
266
+ const rows = await Promise.all(
267
+ writes.map(async ([channel, value], idx) => {
268
+ const [type, serialized] = await this.serde.dumpsTyped(value);
269
+ return {
270
+ // Special channels are stored at fixed negative indices so they never
271
+ // collide with regular per-step writes (whose idx is the ordinal).
272
+ idx: WRITES_IDX_MAP[channel] ?? idx,
273
+ channel,
274
+ type,
275
+ value: serialized,
276
+ };
277
+ }),
278
+ );
279
+
280
+ this.db.exec("BEGIN");
281
+ try {
282
+ for (const row of rows) {
283
+ stmt.run(
284
+ threadId,
285
+ checkpointNs,
286
+ checkpointId,
287
+ taskId,
288
+ row.idx,
289
+ row.channel,
290
+ row.type,
291
+ row.value,
292
+ );
293
+ }
294
+ this.db.exec("COMMIT");
295
+ } catch (err) {
296
+ this.db.exec("ROLLBACK");
297
+ throw err;
298
+ }
299
+ }
300
+
301
+ async deleteThread(threadId: string): Promise<void> {
302
+ this.setup();
303
+
304
+ this.db.exec("BEGIN");
305
+ try {
306
+ this.db.prepare("DELETE FROM checkpoints WHERE thread_id = ?").run(threadId);
307
+ this.db.prepare("DELETE FROM writes WHERE thread_id = ?").run(threadId);
308
+ this.db.exec("COMMIT");
309
+ } catch (err) {
310
+ this.db.exec("ROLLBACK");
311
+ throw err;
312
+ }
313
+ }
314
+
315
+ /** Close the underlying database handle. Idempotent and non-throwing. */
316
+ close(): void {
317
+ try {
318
+ this.db.close();
319
+ } catch {
320
+ /* already closed */
321
+ }
322
+ }
323
+
324
+ /** Deserialize a checkpoint row and hydrate its pending writes. */
325
+ private async rowToTuple(row: CheckpointColumns): Promise<CheckpointTuple> {
326
+ const type = row.type ?? "json";
327
+ const checkpoint = (await this.serde.loadsTyped(type, row.checkpoint)) as Checkpoint;
328
+ const metadata = (await this.serde.loadsTyped(type, row.metadata)) as CheckpointMetadata;
329
+
330
+ const pendingWrites = await this.loadPendingWrites(
331
+ row.thread_id,
332
+ row.checkpoint_ns,
333
+ row.checkpoint_id,
334
+ );
335
+
336
+ return {
337
+ config: {
338
+ configurable: {
339
+ thread_id: row.thread_id,
340
+ checkpoint_ns: row.checkpoint_ns,
341
+ checkpoint_id: row.checkpoint_id,
342
+ },
343
+ },
344
+ checkpoint,
345
+ metadata,
346
+ parentConfig: row.parent_checkpoint_id
347
+ ? {
348
+ configurable: {
349
+ thread_id: row.thread_id,
350
+ checkpoint_ns: row.checkpoint_ns,
351
+ checkpoint_id: row.parent_checkpoint_id,
352
+ },
353
+ }
354
+ : undefined,
355
+ pendingWrites,
356
+ };
357
+ }
358
+
359
+ private async loadPendingWrites(
360
+ threadId: string,
361
+ checkpointNs: string,
362
+ checkpointId: string,
363
+ ): Promise<CheckpointPendingWrite[]> {
364
+ const writeRows = this.db
365
+ .prepare(
366
+ `SELECT task_id, channel, type, value
367
+ FROM writes
368
+ WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?
369
+ ORDER BY task_id, idx`,
370
+ )
371
+ .all(threadId, checkpointNs, checkpointId) as unknown as WriteColumns[];
372
+
373
+ return Promise.all(
374
+ writeRows.map(
375
+ async (w) =>
376
+ [
377
+ w.task_id,
378
+ w.channel,
379
+ await this.serde.loadsTyped(w.type ?? "json", w.value),
380
+ ] as CheckpointPendingWrite,
381
+ ),
382
+ );
383
+ }
384
+ }
@@ -1,12 +1,16 @@
1
1
  /**
2
2
  * Configuration for the checkpointer factory.
3
3
  *
4
- * Two backends are supported:
5
- * - memory: ephemeral in-process storage (OSS / local development)
6
- * - http: routes through the Stigmer Side-Channel Proxy (cloud)
4
+ * Three backends are supported:
5
+ * - sqlite: durable local file (OSS / local / desktop default) — survives across
6
+ * ExecuteDeepAgent invocations so HITL/pause/transient-recovery truly resume.
7
+ * - http: routes through the Stigmer Side-Channel Proxy (cloud / managed).
8
+ * - memory: ephemeral in-process storage — explicit opt-in, used by tests.
7
9
  */
8
10
  export interface CheckpointerConfig {
9
- readonly type: "memory" | "http";
11
+ readonly type: "memory" | "http" | "sqlite";
10
12
  readonly proxyEndpoint?: string;
11
13
  readonly authToken?: string;
14
+ /** Absolute path to the SQLite database file. Required when type is "sqlite". */
15
+ readonly sqlitePath?: string;
12
16
  }