pi-harness-runtime 1.1.31 → 1.1.32
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/harness/langchain/checkpointer.test.ts +269 -0
- package/harness/langchain/checkpointer.ts +343 -0
- package/harness/langchain/daemon.test.ts +45 -0
- package/harness/langchain/daemon.ts +51 -10
- package/harness/langchain/graph.ts +11 -4
- package/harness/langchain/run.ts +3 -1
- package/harness/langchain/surge.test.ts +214 -0
- package/harness/langchain/surge.ts +217 -0
- package/package.json +8 -2
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileCheckpointSaver tests — T9–T11 (wiki/auto-trigger-multi-agent.md §M5)
|
|
3
|
+
*
|
|
4
|
+
* Run:
|
|
5
|
+
* bun test harness/langchain/checkpointer.test.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it } from "node:test";
|
|
9
|
+
import assert from "node:assert";
|
|
10
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
FileCheckpointSaver,
|
|
16
|
+
createLoopCheckpointer,
|
|
17
|
+
} from "./checkpointer.js";
|
|
18
|
+
|
|
19
|
+
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const ROOT = `/tmp/checkpointer-test-${randomUUID().slice(0, 8)}`;
|
|
22
|
+
|
|
23
|
+
function freshSaver(): FileCheckpointSaver {
|
|
24
|
+
const saver = new FileCheckpointSaver({ root: ROOT });
|
|
25
|
+
saver.clearAll();
|
|
26
|
+
return saver;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const THREAD = "thread-abc123";
|
|
30
|
+
const CONFIG = { configurable: { thread_id: THREAD } };
|
|
31
|
+
const CONFIG_WITH_CPID = {
|
|
32
|
+
configurable: { thread_id: THREAD, checkpoint_id: "cp-001" },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// ─── T9: Basic put/get ──────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
describe("T9 — put and get basic checkpoint", () => {
|
|
38
|
+
it("stores and retrieves a checkpoint", async () => {
|
|
39
|
+
const saver = freshSaver();
|
|
40
|
+
|
|
41
|
+
const checkpoint = {
|
|
42
|
+
v: 4,
|
|
43
|
+
id: "cp-001",
|
|
44
|
+
ts: "2026-08-31T10:00:00.000Z",
|
|
45
|
+
channel_values: { plan: "do the thing", code: "" },
|
|
46
|
+
channel_versions: { plan: 1, code: 0 },
|
|
47
|
+
versions_seen: {},
|
|
48
|
+
};
|
|
49
|
+
const metadata = {
|
|
50
|
+
source: "input" as const,
|
|
51
|
+
step: -1,
|
|
52
|
+
parents: {},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const updatedConfig = await saver.put(CONFIG, checkpoint, metadata, {});
|
|
56
|
+
assert.equal(
|
|
57
|
+
updatedConfig.configurable?.checkpoint_id,
|
|
58
|
+
"cp-001",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Retrieve it
|
|
62
|
+
const tuple = await saver.getTuple(CONFIG_WITH_CPID);
|
|
63
|
+
assert.ok(tuple, "should retrieve the checkpoint");
|
|
64
|
+
assert.equal(tuple?.checkpoint.id, "cp-001");
|
|
65
|
+
assert.equal(
|
|
66
|
+
(tuple?.checkpoint.channel_values as Record<string, unknown>)?.plan,
|
|
67
|
+
"do the thing",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("get() returns the checkpoint", async () => {
|
|
72
|
+
const saver = freshSaver();
|
|
73
|
+
const cp = {
|
|
74
|
+
v: 4,
|
|
75
|
+
id: "cp-get",
|
|
76
|
+
ts: "2026-08-31T10:01:00.000Z",
|
|
77
|
+
channel_values: { plan: "test plan" },
|
|
78
|
+
channel_versions: {},
|
|
79
|
+
versions_seen: {},
|
|
80
|
+
};
|
|
81
|
+
await saver.put(CONFIG, cp, { source: "input", step: -1, parents: {} }, {});
|
|
82
|
+
|
|
83
|
+
const retrieved = await saver.get({
|
|
84
|
+
configurable: { thread_id: THREAD, checkpoint_id: "cp-get" },
|
|
85
|
+
});
|
|
86
|
+
assert.ok(retrieved, "should return the checkpoint");
|
|
87
|
+
assert.equal(retrieved?.id, "cp-get");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ─── T10: Parent chain ──────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
describe("T10 — parent chain", () => {
|
|
94
|
+
it("stores and retrieves a parent chain (resume)", async () => {
|
|
95
|
+
const saver = freshSaver();
|
|
96
|
+
|
|
97
|
+
const root = {
|
|
98
|
+
v: 4,
|
|
99
|
+
id: "root",
|
|
100
|
+
ts: "2026-08-31T10:00:00.000Z",
|
|
101
|
+
channel_values: {},
|
|
102
|
+
channel_versions: {},
|
|
103
|
+
versions_seen: {},
|
|
104
|
+
};
|
|
105
|
+
const child = {
|
|
106
|
+
v: 4,
|
|
107
|
+
id: "child",
|
|
108
|
+
ts: "2026-08-31T10:01:00.000Z",
|
|
109
|
+
channel_values: { plan: "the plan" },
|
|
110
|
+
channel_versions: { plan: 1 },
|
|
111
|
+
versions_seen: {},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
await saver.put(
|
|
115
|
+
CONFIG,
|
|
116
|
+
root,
|
|
117
|
+
{ source: "input", step: -1, parents: {} },
|
|
118
|
+
{},
|
|
119
|
+
);
|
|
120
|
+
await saver.put(
|
|
121
|
+
CONFIG,
|
|
122
|
+
child,
|
|
123
|
+
{ source: "loop", step: 0, parents: { "": "root" } },
|
|
124
|
+
{},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Retrieve child and verify parent
|
|
128
|
+
const tuple = await saver.getTuple({
|
|
129
|
+
configurable: { thread_id: THREAD, checkpoint_id: "child" },
|
|
130
|
+
});
|
|
131
|
+
assert.ok(tuple);
|
|
132
|
+
assert.ok(tuple?.parentConfig);
|
|
133
|
+
assert.equal(
|
|
134
|
+
tuple?.parentConfig?.configurable?.checkpoint_id,
|
|
135
|
+
"root",
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// getTuple without checkpoint_id returns the latest
|
|
139
|
+
const latest = await saver.getTuple(CONFIG);
|
|
140
|
+
assert.ok(latest);
|
|
141
|
+
assert.equal(latest?.checkpoint.id, "child");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ─── T11: list + deleteThread ───────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
describe("T11 — list and deleteThread", () => {
|
|
148
|
+
it("lists checkpoints in reverse chronological order", async () => {
|
|
149
|
+
const saver = freshSaver();
|
|
150
|
+
|
|
151
|
+
for (let i = 0; i < 5; i++) {
|
|
152
|
+
const cp = {
|
|
153
|
+
v: 4,
|
|
154
|
+
id: `cp-${i}`,
|
|
155
|
+
ts: new Date(1000 + i * 1000).toISOString(),
|
|
156
|
+
channel_values: { step: i },
|
|
157
|
+
channel_versions: {},
|
|
158
|
+
versions_seen: {},
|
|
159
|
+
};
|
|
160
|
+
await saver.put(
|
|
161
|
+
CONFIG,
|
|
162
|
+
cp,
|
|
163
|
+
{ source: "loop", step: i, parents: {} },
|
|
164
|
+
{},
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const all: string[] = [];
|
|
169
|
+
for await (const tuple of saver.list(CONFIG)) {
|
|
170
|
+
all.push(tuple.checkpoint.id);
|
|
171
|
+
}
|
|
172
|
+
assert.equal(all.length, 5);
|
|
173
|
+
// Newest first
|
|
174
|
+
assert.equal(all[0], "cp-4");
|
|
175
|
+
assert.equal(all[4], "cp-0");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("limits list results", async () => {
|
|
179
|
+
const saver = freshSaver();
|
|
180
|
+
for (let i = 0; i < 3; i++) {
|
|
181
|
+
const cp = {
|
|
182
|
+
v: 4,
|
|
183
|
+
id: `limit-${i}`,
|
|
184
|
+
ts: new Date(2000 + i * 1000).toISOString(),
|
|
185
|
+
channel_values: {},
|
|
186
|
+
channel_versions: {},
|
|
187
|
+
versions_seen: {},
|
|
188
|
+
};
|
|
189
|
+
await saver.put(
|
|
190
|
+
CONFIG,
|
|
191
|
+
cp,
|
|
192
|
+
{ source: "loop", step: i, parents: {} },
|
|
193
|
+
{},
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const limited: string[] = [];
|
|
198
|
+
for await (const tuple of saver.list(CONFIG, { limit: 2 })) {
|
|
199
|
+
limited.push(tuple.checkpoint.id);
|
|
200
|
+
}
|
|
201
|
+
assert.equal(limited.length, 2);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("deleteThread removes all checkpoints for a thread", async () => {
|
|
205
|
+
const saver = freshSaver();
|
|
206
|
+
|
|
207
|
+
const cp = {
|
|
208
|
+
v: 4,
|
|
209
|
+
id: "delete-me",
|
|
210
|
+
ts: "2026-08-31T12:00:00.000Z",
|
|
211
|
+
channel_values: {},
|
|
212
|
+
channel_versions: {},
|
|
213
|
+
versions_seen: {},
|
|
214
|
+
};
|
|
215
|
+
await saver.put(
|
|
216
|
+
CONFIG,
|
|
217
|
+
cp,
|
|
218
|
+
{ source: "input", step: -1, parents: {} },
|
|
219
|
+
{},
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
assert.ok(await saver.getTuple(CONFIG), "checkpoint should exist");
|
|
223
|
+
|
|
224
|
+
await saver.deleteThread(THREAD);
|
|
225
|
+
|
|
226
|
+
assert.ok(
|
|
227
|
+
!(await saver.getTuple(CONFIG)),
|
|
228
|
+
"checkpoint should be gone after deleteThread",
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("toJSON returns a string (not the internal object)", async () => {
|
|
233
|
+
const saver = freshSaver();
|
|
234
|
+
const json = saver.toJSON();
|
|
235
|
+
assert.equal(typeof json, "string");
|
|
236
|
+
assert.ok(json.length > 0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("listThreads returns thread ids", async () => {
|
|
240
|
+
const saver = freshSaver();
|
|
241
|
+
const tid = `thread-${randomUUID().slice(0, 6)}`;
|
|
242
|
+
const cp = {
|
|
243
|
+
v: 4,
|
|
244
|
+
id: "cp-t",
|
|
245
|
+
ts: "2026-08-31T12:00:00.000Z",
|
|
246
|
+
channel_values: {},
|
|
247
|
+
channel_versions: {},
|
|
248
|
+
versions_seen: {},
|
|
249
|
+
};
|
|
250
|
+
await saver.put(
|
|
251
|
+
{ configurable: { thread_id: tid } },
|
|
252
|
+
cp,
|
|
253
|
+
{ source: "input", step: -1, parents: {} },
|
|
254
|
+
{},
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const threads = saver.listThreads();
|
|
258
|
+
assert.ok(threads.includes(tid), `Expected ${tid} in ${JSON.stringify(threads)}`);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ─── T12: createLoopCheckpointer factory ─────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
describe("T12 — createLoopCheckpointer factory", () => {
|
|
265
|
+
it("creates a saver with the correct root path", () => {
|
|
266
|
+
const saver = createLoopCheckpointer("/my/workspace");
|
|
267
|
+
assert.ok(saver instanceof FileCheckpointSaver);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based checkpointer — persists LangGraph StateGraph checkpoints to disk.
|
|
3
|
+
*
|
|
4
|
+
* Enables crash-safe loop resume: if the daemon dies mid-task, a new instance
|
|
5
|
+
* resumes from the last checkpoint instead of restarting from scratch.
|
|
6
|
+
*
|
|
7
|
+
* Storage layout:
|
|
8
|
+
* <root>/
|
|
9
|
+
* ├── <threadId>/
|
|
10
|
+
* │ ├── index.jsonl # one line per checkpoint (id, ts, parentId, step)
|
|
11
|
+
* │ └── <checkpointId>.json # full checkpoint + metadata + parentConfig
|
|
12
|
+
*
|
|
13
|
+
* Wiki: wiki/auto-trigger-multi-agent.md §M5
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
readdirSync,
|
|
21
|
+
renameSync,
|
|
22
|
+
unlinkSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
BaseCheckpointSaver,
|
|
29
|
+
Checkpoint,
|
|
30
|
+
CheckpointListOptions,
|
|
31
|
+
CheckpointMetadata,
|
|
32
|
+
CheckpointTuple,
|
|
33
|
+
} from "@langchain/langgraph-checkpoint";
|
|
34
|
+
import type { ChannelVersions } from "@langchain/langgraph-checkpoint";
|
|
35
|
+
import type { RunnableConfig } from "@langchain/core/runnables";
|
|
36
|
+
|
|
37
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** Minimal JSON serializer compatible with SerializerProtocol. */
|
|
40
|
+
const jsonSerializer = {
|
|
41
|
+
async dumpsTyped(data: unknown): Promise<[string, Uint8Array]> {
|
|
42
|
+
return ["application/json", new TextEncoder().encode(JSON.stringify(data))];
|
|
43
|
+
},
|
|
44
|
+
async loadsTyped(
|
|
45
|
+
_type: string,
|
|
46
|
+
data: Uint8Array | string,
|
|
47
|
+
): Promise<unknown> {
|
|
48
|
+
try {
|
|
49
|
+
const bytes: Uint8Array =
|
|
50
|
+
typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
51
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error("Failed to deserialize checkpoint data");
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
// ─── Storage helpers ────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
interface IndexEntry {
|
|
61
|
+
id: string;
|
|
62
|
+
ts: string;
|
|
63
|
+
parentId?: string;
|
|
64
|
+
step: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function threadDir(root: string, threadId: string): string {
|
|
68
|
+
return join(root, `thread-${threadId}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function checkpointPath(threadDir: string, checkpointId: string): string {
|
|
72
|
+
return join(threadDir, `${checkpointId}.json`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function indexPath(threadDir: string): string {
|
|
76
|
+
return join(threadDir, "index.jsonl");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ensureThreadDir(root: string, threadId: string): string {
|
|
80
|
+
const dir = threadDir(root, threadId);
|
|
81
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
82
|
+
return dir;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readIndex(threadDir: string): IndexEntry[] {
|
|
86
|
+
const path = indexPath(threadDir);
|
|
87
|
+
if (!existsSync(path)) return [];
|
|
88
|
+
try {
|
|
89
|
+
return readFileSync(path, "utf8")
|
|
90
|
+
.split("\n")
|
|
91
|
+
.filter((l) => l.trim())
|
|
92
|
+
.map((l) => JSON.parse(l) as IndexEntry);
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function appendIndex(threadDir: string, entry: IndexEntry): void {
|
|
99
|
+
const path = indexPath(threadDir);
|
|
100
|
+
const line = JSON.stringify(entry) + "\n";
|
|
101
|
+
if (existsSync(path)) {
|
|
102
|
+
const fd = require("node:fs").openSync(path, "a");
|
|
103
|
+
require("node:fs").writeSync(fd, line);
|
|
104
|
+
require("node:fs").closeSync(fd);
|
|
105
|
+
} else {
|
|
106
|
+
writeFileSync(path, line, "utf8");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function atomicWrite(path: string, content: string): void {
|
|
111
|
+
const tmp = `${path}.tmp`;
|
|
112
|
+
writeFileSync(tmp, content, "utf8");
|
|
113
|
+
renameSync(tmp, path);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── FileCheckpointSaver ──────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* File-based LangGraph checkpointer. Implements BaseCheckpointSaver so it can be
|
|
120
|
+
* passed directly to `buildWriteReviewLoop()` as the `checkpointer` option.
|
|
121
|
+
*
|
|
122
|
+
* Usage:
|
|
123
|
+
* const saver = new FileCheckpointSaver({ root: "/tmp/checkpoints" });
|
|
124
|
+
* const loop = buildWriteReviewLoop(deps, { checkpointer: saver });
|
|
125
|
+
*
|
|
126
|
+
* On daemon restart, pass the same root — checkpoints persist across restarts.
|
|
127
|
+
*/
|
|
128
|
+
export class FileCheckpointSaver extends BaseCheckpointSaver {
|
|
129
|
+
private readonly root: string;
|
|
130
|
+
|
|
131
|
+
constructor(opts: { root: string }) {
|
|
132
|
+
super(jsonSerializer);
|
|
133
|
+
this.root = opts.root;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── BaseCheckpointSaver ─────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
async get(config: RunnableConfig): Promise<Checkpoint | undefined> {
|
|
139
|
+
const tuple = await this.getTuple(config);
|
|
140
|
+
return tuple?.checkpoint;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async getTuple(
|
|
144
|
+
config: RunnableConfig,
|
|
145
|
+
): Promise<CheckpointTuple | undefined> {
|
|
146
|
+
const threadId = String(config.configurable?.thread_id ?? "");
|
|
147
|
+
if (!threadId) return undefined;
|
|
148
|
+
|
|
149
|
+
const dir = threadDir(this.root, threadId);
|
|
150
|
+
if (!existsSync(dir)) return undefined;
|
|
151
|
+
|
|
152
|
+
const checkpointId = String(config.configurable?.checkpoint_id ?? "");
|
|
153
|
+
if (!checkpointId) {
|
|
154
|
+
const index = readIndex(dir);
|
|
155
|
+
if (index.length === 0) return undefined;
|
|
156
|
+
return this._loadCheckpoint(config, dir, index[index.length - 1].id);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return this._loadCheckpoint(config, dir, checkpointId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async _loadCheckpoint(
|
|
163
|
+
config: RunnableConfig,
|
|
164
|
+
dir: string,
|
|
165
|
+
checkpointId: string,
|
|
166
|
+
): Promise<CheckpointTuple | undefined> {
|
|
167
|
+
const path = checkpointPath(dir, checkpointId);
|
|
168
|
+
if (!existsSync(path)) return undefined;
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const raw = readFileSync(path);
|
|
172
|
+
const data = JSON.parse(
|
|
173
|
+
new TextDecoder().decode(raw),
|
|
174
|
+
) as {
|
|
175
|
+
checkpoint: Checkpoint;
|
|
176
|
+
metadata: CheckpointMetadata;
|
|
177
|
+
parentConfig?: RunnableConfig;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const index = readIndex(dir);
|
|
181
|
+
const entry = index.find((e) => e.id === checkpointId);
|
|
182
|
+
const parentId = entry?.parentId;
|
|
183
|
+
|
|
184
|
+
let parentConfig: RunnableConfig | undefined;
|
|
185
|
+
if (
|
|
186
|
+
parentId &&
|
|
187
|
+
existsSync(checkpointPath(dir, parentId))
|
|
188
|
+
) {
|
|
189
|
+
parentConfig = {
|
|
190
|
+
configurable: {
|
|
191
|
+
thread_id: config.configurable?.thread_id,
|
|
192
|
+
checkpoint_id: parentId,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
config,
|
|
199
|
+
checkpoint: data.checkpoint,
|
|
200
|
+
metadata: data.metadata,
|
|
201
|
+
parentConfig: parentConfig ?? data.parentConfig,
|
|
202
|
+
};
|
|
203
|
+
} catch {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async *list(
|
|
209
|
+
config: RunnableConfig,
|
|
210
|
+
options?: CheckpointListOptions,
|
|
211
|
+
): AsyncGenerator<CheckpointTuple> {
|
|
212
|
+
const threadId = String(config.configurable?.thread_id ?? "");
|
|
213
|
+
if (!threadId) return;
|
|
214
|
+
|
|
215
|
+
const dir = threadDir(this.root, threadId);
|
|
216
|
+
if (!existsSync(dir)) return;
|
|
217
|
+
|
|
218
|
+
const index = [...readIndex(dir)].sort((a, b) =>
|
|
219
|
+
b.ts.localeCompare(a.ts),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const limit = options?.limit ?? 100;
|
|
223
|
+
let count = 0;
|
|
224
|
+
|
|
225
|
+
for (const entry of index) {
|
|
226
|
+
if (count >= limit) break;
|
|
227
|
+
const tuple = await this._loadCheckpoint(config, dir, entry.id);
|
|
228
|
+
if (tuple) yield tuple;
|
|
229
|
+
count++;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async put(
|
|
234
|
+
config: RunnableConfig,
|
|
235
|
+
checkpoint: Checkpoint,
|
|
236
|
+
metadata: CheckpointMetadata,
|
|
237
|
+
_newVersions: ChannelVersions,
|
|
238
|
+
): Promise<RunnableConfig> {
|
|
239
|
+
const threadId = String(config.configurable?.thread_id ?? "");
|
|
240
|
+
if (!threadId) throw new Error("thread_id is required for checkpointing");
|
|
241
|
+
|
|
242
|
+
const dir = ensureThreadDir(this.root, threadId);
|
|
243
|
+
const parentId = (metadata.parents as Record<string, string | undefined>)?.[""];
|
|
244
|
+
|
|
245
|
+
const path = checkpointPath(dir, checkpoint.id);
|
|
246
|
+
atomicWrite(
|
|
247
|
+
path,
|
|
248
|
+
JSON.stringify({
|
|
249
|
+
checkpoint,
|
|
250
|
+
metadata,
|
|
251
|
+
parentConfig: parentId
|
|
252
|
+
? { configurable: { thread_id: threadId, checkpoint_id: parentId } }
|
|
253
|
+
: undefined,
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
appendIndex(dir, {
|
|
258
|
+
id: checkpoint.id,
|
|
259
|
+
ts: checkpoint.ts,
|
|
260
|
+
parentId,
|
|
261
|
+
step: metadata.step,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
configurable: { ...config.configurable, checkpoint_id: checkpoint.id },
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async putWrites(
|
|
270
|
+
_config: RunnableConfig,
|
|
271
|
+
_writes: [],
|
|
272
|
+
_taskId: string,
|
|
273
|
+
): Promise<void> {
|
|
274
|
+
// Pending writes stored inline in the checkpoint JSON — no separate file needed.
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async deleteThread(threadId: string): Promise<void> {
|
|
278
|
+
const dir = threadDir(this.root, threadId);
|
|
279
|
+
if (!existsSync(dir)) return;
|
|
280
|
+
|
|
281
|
+
for (const file of readdirSync(dir)) {
|
|
282
|
+
try {
|
|
283
|
+
unlinkSync(join(dir, file));
|
|
284
|
+
} catch {
|
|
285
|
+
/* ignore */
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
require("node:fs").rmdirSync(dir);
|
|
291
|
+
} catch {
|
|
292
|
+
/* ignore */
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
toJSON(): string {
|
|
297
|
+
return "FileCheckpointSaver";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ─── Inherited defaults from BaseCheckpointSaver ───────────────────────
|
|
301
|
+
|
|
302
|
+
// getDeltaChannelHistory: uses the default implementation (walks getTuple chain)
|
|
303
|
+
// getNextVersion: uses the default integer implementation
|
|
304
|
+
|
|
305
|
+
// ─── Helpers ───────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
/** List all known thread IDs with saved checkpoints. */
|
|
308
|
+
listThreads(): string[] {
|
|
309
|
+
if (!existsSync(this.root)) return [];
|
|
310
|
+
return readdirSync(this.root)
|
|
311
|
+
.filter((f) => f.startsWith("thread-"))
|
|
312
|
+
.map((f) => f.replace("thread-", ""));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Delete all checkpoints for all threads (cleanup). */
|
|
316
|
+
clearAll(): void {
|
|
317
|
+
if (!existsSync(this.root)) return;
|
|
318
|
+
for (const thread of readdirSync(this.root)) {
|
|
319
|
+
const fullPath = join(this.root, thread);
|
|
320
|
+
if (!existsSync(fullPath)) continue;
|
|
321
|
+
for (const file of readdirSync(fullPath)) {
|
|
322
|
+
try {
|
|
323
|
+
unlinkSync(join(fullPath, file));
|
|
324
|
+
} catch {
|
|
325
|
+
/* ignore */
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
require("node:fs").rmdirSync(fullPath);
|
|
330
|
+
} catch {
|
|
331
|
+
/* ignore */
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
/** Build a FileCheckpointSaver wired to the daemon's workspace. */
|
|
340
|
+
export function createLoopCheckpointer(workspace: string): FileCheckpointSaver {
|
|
341
|
+
const root = join(workspace, ".checkpoints");
|
|
342
|
+
return new FileCheckpointSaver({ root });
|
|
343
|
+
}
|
|
@@ -351,3 +351,48 @@ describe("T7 — crash resilience", () => {
|
|
|
351
351
|
}
|
|
352
352
|
});
|
|
353
353
|
});
|
|
354
|
+
|
|
355
|
+
// ─── T13: Persistent checkpointer (M5) ──────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
describe("T13 — persistent checkpointer (M5)", () => {
|
|
358
|
+
it("loop checkpoints survive daemon stop and resume from last checkpoint", async () => {
|
|
359
|
+
cleanEvents();
|
|
360
|
+
// Use persistent checkpointer (true = use workspace root for checkpoints)
|
|
361
|
+
const daemon1 = new LoopDaemon(
|
|
362
|
+
testConfig({ sources: ["inbox"], checkpointer: true }),
|
|
363
|
+
);
|
|
364
|
+
daemon1.start();
|
|
365
|
+
|
|
366
|
+
const filename = `test-T13-${randomUUID().slice(0, 6)}.md`;
|
|
367
|
+
writeFileSync(join(WORKSPACE, filename), "Persistent checkpoint test", "utf8");
|
|
368
|
+
|
|
369
|
+
// Let the loop run for one iteration
|
|
370
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
371
|
+
daemon1.stop();
|
|
372
|
+
|
|
373
|
+
// Verify checkpoints exist on disk
|
|
374
|
+
const { createLoopCheckpointer } = await import("./checkpointer.js");
|
|
375
|
+
const saver = createLoopCheckpointer(WORKSPACE);
|
|
376
|
+
const threads = saver.listThreads();
|
|
377
|
+
assert.ok(
|
|
378
|
+
threads.length > 0,
|
|
379
|
+
`Expected checkpoint threads, got: ${JSON.stringify(threads)}`,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// Resume with new daemon — should pick up from checkpoint
|
|
383
|
+
const daemon2 = new LoopDaemon(
|
|
384
|
+
testConfig({ sources: ["inbox"], checkpointer: true }),
|
|
385
|
+
);
|
|
386
|
+
daemon2.start();
|
|
387
|
+
try {
|
|
388
|
+
await waitForEvent("loop.finished", 15_000);
|
|
389
|
+
assert.ok(
|
|
390
|
+
countEvents("loop.finished") >= 1,
|
|
391
|
+
"Expected loop.finished after resume",
|
|
392
|
+
);
|
|
393
|
+
} finally {
|
|
394
|
+
daemon2.stop();
|
|
395
|
+
saver.clearAll();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
});
|