@wundr.io/langgraph-orchestrator 1.0.2-dev.20260530174250.ef0ec927
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +842 -0
- package/package.json +60 -0
- package/src/checkpointing.ts +702 -0
- package/src/edges/conditional-edge.ts +518 -0
- package/src/edges/loop-edge.ts +623 -0
- package/src/index.ts +416 -0
- package/src/nodes/decision-node.ts +538 -0
- package/src/nodes/human-node.ts +572 -0
- package/src/nodes/llm-node.ts +448 -0
- package/src/nodes/tool-node.ts +525 -0
- package/src/prebuilt-graphs/plan-execute-refine.ts +769 -0
- package/src/state-graph.ts +990 -0
- package/src/types.ts +729 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkpointing - State persistence and time-travel debugging
|
|
3
|
+
* @module @wundr.io/langgraph-orchestrator
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
AgentState,
|
|
11
|
+
Checkpoint,
|
|
12
|
+
CheckpointSummary,
|
|
13
|
+
GraphCheckpointer,
|
|
14
|
+
} from './types';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* In-memory checkpointer implementation
|
|
18
|
+
* Useful for development and testing
|
|
19
|
+
*/
|
|
20
|
+
export class MemoryCheckpointer implements GraphCheckpointer {
|
|
21
|
+
private checkpoints: Map<string, Checkpoint> = new Map();
|
|
22
|
+
private executionIndex: Map<string, string[]> = new Map();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Save a checkpoint
|
|
26
|
+
*/
|
|
27
|
+
async save(checkpoint: Checkpoint): Promise<void> {
|
|
28
|
+
this.checkpoints.set(checkpoint.id, checkpoint);
|
|
29
|
+
|
|
30
|
+
// Update execution index
|
|
31
|
+
const executionCheckpoints =
|
|
32
|
+
this.executionIndex.get(checkpoint.executionId) ?? [];
|
|
33
|
+
executionCheckpoints.push(checkpoint.id);
|
|
34
|
+
this.executionIndex.set(checkpoint.executionId, executionCheckpoints);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load a checkpoint by ID
|
|
39
|
+
*/
|
|
40
|
+
async load(checkpointId: string): Promise<Checkpoint | null> {
|
|
41
|
+
return this.checkpoints.get(checkpointId) ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* List checkpoints for an execution
|
|
46
|
+
*/
|
|
47
|
+
async list(executionId: string): Promise<CheckpointSummary[]> {
|
|
48
|
+
const checkpointIds = this.executionIndex.get(executionId) ?? [];
|
|
49
|
+
return checkpointIds
|
|
50
|
+
.map(id => {
|
|
51
|
+
const cp = this.checkpoints.get(id);
|
|
52
|
+
if (!cp) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
id: cp.id,
|
|
57
|
+
executionId: cp.executionId,
|
|
58
|
+
stepNumber: cp.stepNumber,
|
|
59
|
+
nodeName: cp.nodeName,
|
|
60
|
+
timestamp: cp.timestamp,
|
|
61
|
+
};
|
|
62
|
+
})
|
|
63
|
+
.filter((cp): cp is CheckpointSummary => cp !== null)
|
|
64
|
+
.sort((a, b) => a.stepNumber - b.stepNumber);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Delete a checkpoint
|
|
69
|
+
*/
|
|
70
|
+
async delete(checkpointId: string): Promise<void> {
|
|
71
|
+
const checkpoint = this.checkpoints.get(checkpointId);
|
|
72
|
+
if (checkpoint) {
|
|
73
|
+
this.checkpoints.delete(checkpointId);
|
|
74
|
+
|
|
75
|
+
// Update execution index
|
|
76
|
+
const executionCheckpoints =
|
|
77
|
+
this.executionIndex.get(checkpoint.executionId) ?? [];
|
|
78
|
+
const index = executionCheckpoints.indexOf(checkpointId);
|
|
79
|
+
if (index > -1) {
|
|
80
|
+
executionCheckpoints.splice(index, 1);
|
|
81
|
+
this.executionIndex.set(checkpoint.executionId, executionCheckpoints);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get the latest checkpoint for an execution
|
|
88
|
+
*/
|
|
89
|
+
async getLatest(executionId: string): Promise<Checkpoint | null> {
|
|
90
|
+
const checkpointIds = this.executionIndex.get(executionId) ?? [];
|
|
91
|
+
if (checkpointIds.length === 0) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Get the most recent checkpoint
|
|
96
|
+
let latest: Checkpoint | null = null;
|
|
97
|
+
for (const id of checkpointIds) {
|
|
98
|
+
const cp = this.checkpoints.get(id);
|
|
99
|
+
if (cp && (!latest || cp.stepNumber > latest.stepNumber)) {
|
|
100
|
+
latest = cp;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return latest;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Clear all checkpoints
|
|
109
|
+
*/
|
|
110
|
+
clear(): void {
|
|
111
|
+
this.checkpoints.clear();
|
|
112
|
+
this.executionIndex.clear();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get statistics about stored checkpoints
|
|
117
|
+
*/
|
|
118
|
+
getStats(): { totalCheckpoints: number; totalExecutions: number } {
|
|
119
|
+
return {
|
|
120
|
+
totalCheckpoints: this.checkpoints.size,
|
|
121
|
+
totalExecutions: this.executionIndex.size,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* File-based checkpointer implementation
|
|
128
|
+
* Persists checkpoints to the filesystem
|
|
129
|
+
*/
|
|
130
|
+
export class FileCheckpointer implements GraphCheckpointer {
|
|
131
|
+
private readonly basePath: string;
|
|
132
|
+
private readonly fs: FileSystem;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Create a file-based checkpointer
|
|
136
|
+
* @param basePath - Directory to store checkpoints
|
|
137
|
+
* @param fs - FileSystem interface (allows for mocking)
|
|
138
|
+
*/
|
|
139
|
+
constructor(basePath: string, fs?: FileSystem) {
|
|
140
|
+
this.basePath = basePath;
|
|
141
|
+
this.fs = fs ?? createNodeFileSystem();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Save a checkpoint
|
|
146
|
+
*/
|
|
147
|
+
async save(checkpoint: Checkpoint): Promise<void> {
|
|
148
|
+
const executionDir = `${this.basePath}/${checkpoint.executionId}`;
|
|
149
|
+
await this.fs.mkdir(executionDir, { recursive: true });
|
|
150
|
+
|
|
151
|
+
const filePath = `${executionDir}/${checkpoint.id}.json`;
|
|
152
|
+
await this.fs.writeFile(filePath, JSON.stringify(checkpoint, null, 2));
|
|
153
|
+
|
|
154
|
+
// Update index
|
|
155
|
+
const indexPath = `${executionDir}/index.json`;
|
|
156
|
+
const index = await this.loadIndex(indexPath);
|
|
157
|
+
index.push({
|
|
158
|
+
id: checkpoint.id,
|
|
159
|
+
stepNumber: checkpoint.stepNumber,
|
|
160
|
+
nodeName: checkpoint.nodeName,
|
|
161
|
+
timestamp: checkpoint.timestamp.toISOString(),
|
|
162
|
+
});
|
|
163
|
+
await this.fs.writeFile(indexPath, JSON.stringify(index, null, 2));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Load a checkpoint by ID
|
|
168
|
+
*/
|
|
169
|
+
async load(checkpointId: string): Promise<Checkpoint | null> {
|
|
170
|
+
// Search all execution directories
|
|
171
|
+
const executions = await this.fs.readdir(this.basePath).catch(() => []);
|
|
172
|
+
|
|
173
|
+
for (const executionId of executions) {
|
|
174
|
+
const filePath = `${this.basePath}/${executionId}/${checkpointId}.json`;
|
|
175
|
+
try {
|
|
176
|
+
const content = await this.fs.readFile(filePath, 'utf-8');
|
|
177
|
+
const data = JSON.parse(content);
|
|
178
|
+
return {
|
|
179
|
+
...data,
|
|
180
|
+
timestamp: new Date(data.timestamp),
|
|
181
|
+
state: {
|
|
182
|
+
...data.state,
|
|
183
|
+
createdAt: new Date(data.state.createdAt),
|
|
184
|
+
updatedAt: new Date(data.state.updatedAt),
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
} catch {
|
|
188
|
+
// Continue searching
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* List checkpoints for an execution
|
|
197
|
+
*/
|
|
198
|
+
async list(executionId: string): Promise<CheckpointSummary[]> {
|
|
199
|
+
const indexPath = `${this.basePath}/${executionId}/index.json`;
|
|
200
|
+
const index = await this.loadIndex(indexPath);
|
|
201
|
+
|
|
202
|
+
return index.map(item => ({
|
|
203
|
+
id: item.id,
|
|
204
|
+
executionId,
|
|
205
|
+
stepNumber: item.stepNumber,
|
|
206
|
+
nodeName: item.nodeName,
|
|
207
|
+
timestamp: new Date(item.timestamp),
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Delete a checkpoint
|
|
213
|
+
*/
|
|
214
|
+
async delete(checkpointId: string): Promise<void> {
|
|
215
|
+
const checkpoint = await this.load(checkpointId);
|
|
216
|
+
if (!checkpoint) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const filePath = `${this.basePath}/${checkpoint.executionId}/${checkpointId}.json`;
|
|
221
|
+
await this.fs.unlink(filePath).catch(() => {});
|
|
222
|
+
|
|
223
|
+
// Update index
|
|
224
|
+
const indexPath = `${this.basePath}/${checkpoint.executionId}/index.json`;
|
|
225
|
+
const index = await this.loadIndex(indexPath);
|
|
226
|
+
const filtered = index.filter(item => item.id !== checkpointId);
|
|
227
|
+
await this.fs.writeFile(indexPath, JSON.stringify(filtered, null, 2));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Get the latest checkpoint for an execution
|
|
232
|
+
*/
|
|
233
|
+
async getLatest(executionId: string): Promise<Checkpoint | null> {
|
|
234
|
+
const summaries = await this.list(executionId);
|
|
235
|
+
if (summaries.length === 0) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const latest = summaries.reduce((a, b) =>
|
|
240
|
+
a.stepNumber > b.stepNumber ? a : b,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
return this.load(latest.id);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Load index file
|
|
248
|
+
*/
|
|
249
|
+
private async loadIndex(path: string): Promise<
|
|
250
|
+
Array<{
|
|
251
|
+
id: string;
|
|
252
|
+
stepNumber: number;
|
|
253
|
+
nodeName: string;
|
|
254
|
+
timestamp: string;
|
|
255
|
+
}>
|
|
256
|
+
> {
|
|
257
|
+
try {
|
|
258
|
+
const content = await this.fs.readFile(path, 'utf-8');
|
|
259
|
+
return JSON.parse(content);
|
|
260
|
+
} catch {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* FileSystem interface for abstraction
|
|
268
|
+
*/
|
|
269
|
+
export interface FileSystem {
|
|
270
|
+
readFile(path: string, encoding: string): Promise<string>;
|
|
271
|
+
writeFile(path: string, data: string): Promise<void>;
|
|
272
|
+
mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
|
|
273
|
+
readdir(path: string): Promise<string[]>;
|
|
274
|
+
unlink(path: string): Promise<void>;
|
|
275
|
+
exists(path: string): Promise<boolean>;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Create a Node.js filesystem implementation
|
|
280
|
+
*/
|
|
281
|
+
function createNodeFileSystem(): FileSystem {
|
|
282
|
+
// Lazy import to avoid issues in browser environments
|
|
283
|
+
return {
|
|
284
|
+
async readFile(path: string, encoding: string): Promise<string> {
|
|
285
|
+
const fs = await import('fs').then(m => m.promises);
|
|
286
|
+
return fs.readFile(path, encoding as BufferEncoding);
|
|
287
|
+
},
|
|
288
|
+
async writeFile(path: string, data: string): Promise<void> {
|
|
289
|
+
const fs = await import('fs').then(m => m.promises);
|
|
290
|
+
await fs.writeFile(path, data);
|
|
291
|
+
},
|
|
292
|
+
async mkdir(
|
|
293
|
+
path: string,
|
|
294
|
+
options?: { recursive?: boolean },
|
|
295
|
+
): Promise<void> {
|
|
296
|
+
const fs = await import('fs').then(m => m.promises);
|
|
297
|
+
await fs.mkdir(path, options);
|
|
298
|
+
},
|
|
299
|
+
async readdir(path: string): Promise<string[]> {
|
|
300
|
+
const fs = await import('fs').then(m => m.promises);
|
|
301
|
+
return fs.readdir(path);
|
|
302
|
+
},
|
|
303
|
+
async unlink(path: string): Promise<void> {
|
|
304
|
+
const fs = await import('fs').then(m => m.promises);
|
|
305
|
+
await fs.unlink(path);
|
|
306
|
+
},
|
|
307
|
+
async exists(path: string): Promise<boolean> {
|
|
308
|
+
const fs = await import('fs').then(m => m.promises);
|
|
309
|
+
try {
|
|
310
|
+
await fs.access(path);
|
|
311
|
+
return true;
|
|
312
|
+
} catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Time-travel debugger for workflow state
|
|
321
|
+
*/
|
|
322
|
+
export class TimeTravelDebugger<TState extends AgentState = AgentState> {
|
|
323
|
+
private readonly checkpointer: GraphCheckpointer;
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Create a time-travel debugger
|
|
327
|
+
* @param checkpointer - Checkpointer to use for state access
|
|
328
|
+
*/
|
|
329
|
+
constructor(checkpointer: GraphCheckpointer) {
|
|
330
|
+
this.checkpointer = checkpointer;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Get the execution timeline
|
|
335
|
+
* @param executionId - Execution to get timeline for
|
|
336
|
+
* @returns Array of checkpoint summaries
|
|
337
|
+
*/
|
|
338
|
+
async getTimeline(executionId: string): Promise<CheckpointSummary[]> {
|
|
339
|
+
return this.checkpointer.list(executionId);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Travel to a specific checkpoint
|
|
344
|
+
* @param checkpointId - Checkpoint to travel to
|
|
345
|
+
* @returns The state at that checkpoint
|
|
346
|
+
*/
|
|
347
|
+
async travelTo(checkpointId: string): Promise<TState | null> {
|
|
348
|
+
const checkpoint = await this.checkpointer.load(checkpointId);
|
|
349
|
+
return (checkpoint?.state as TState) ?? null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Get state at a specific step number
|
|
354
|
+
* @param executionId - Execution ID
|
|
355
|
+
* @param stepNumber - Step number to travel to
|
|
356
|
+
* @returns The state at that step
|
|
357
|
+
*/
|
|
358
|
+
async travelToStep(
|
|
359
|
+
executionId: string,
|
|
360
|
+
stepNumber: number,
|
|
361
|
+
): Promise<TState | null> {
|
|
362
|
+
const summaries = await this.checkpointer.list(executionId);
|
|
363
|
+
const summary = summaries.find(s => s.stepNumber === stepNumber);
|
|
364
|
+
if (!summary) {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
return this.travelTo(summary.id);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Compare two checkpoints
|
|
372
|
+
* @param checkpointId1 - First checkpoint
|
|
373
|
+
* @param checkpointId2 - Second checkpoint
|
|
374
|
+
* @returns Differences between the checkpoints
|
|
375
|
+
*/
|
|
376
|
+
async compare(
|
|
377
|
+
checkpointId1: string,
|
|
378
|
+
checkpointId2: string,
|
|
379
|
+
): Promise<StateDiff[]> {
|
|
380
|
+
const [cp1, cp2] = await Promise.all([
|
|
381
|
+
this.checkpointer.load(checkpointId1),
|
|
382
|
+
this.checkpointer.load(checkpointId2),
|
|
383
|
+
]);
|
|
384
|
+
|
|
385
|
+
if (!cp1 || !cp2) {
|
|
386
|
+
throw new Error('One or both checkpoints not found');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return this.diffStates(cp1.state, cp2.state);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Get the state history (changes over time)
|
|
394
|
+
* @param executionId - Execution ID
|
|
395
|
+
* @returns Array of state changes
|
|
396
|
+
*/
|
|
397
|
+
async getStateHistory(
|
|
398
|
+
executionId: string,
|
|
399
|
+
): Promise<StateHistoryItem<TState>[]> {
|
|
400
|
+
const summaries = await this.checkpointer.list(executionId);
|
|
401
|
+
const history: StateHistoryItem<TState>[] = [];
|
|
402
|
+
|
|
403
|
+
let previousState: AgentState | null = null;
|
|
404
|
+
|
|
405
|
+
for (const summary of summaries) {
|
|
406
|
+
const checkpoint = await this.checkpointer.load(summary.id);
|
|
407
|
+
if (!checkpoint) {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const changes = previousState
|
|
412
|
+
? this.diffStates(previousState, checkpoint.state)
|
|
413
|
+
: [];
|
|
414
|
+
|
|
415
|
+
history.push({
|
|
416
|
+
checkpoint: summary,
|
|
417
|
+
state: checkpoint.state as TState,
|
|
418
|
+
changes,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
previousState = checkpoint.state;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return history;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Find checkpoints where a condition became true
|
|
429
|
+
* @param executionId - Execution ID
|
|
430
|
+
* @param condition - Condition to check
|
|
431
|
+
* @returns Checkpoints where condition became true
|
|
432
|
+
*/
|
|
433
|
+
async findTransitions(
|
|
434
|
+
executionId: string,
|
|
435
|
+
condition: (state: TState) => boolean,
|
|
436
|
+
): Promise<CheckpointSummary[]> {
|
|
437
|
+
const summaries = await this.checkpointer.list(executionId);
|
|
438
|
+
const transitions: CheckpointSummary[] = [];
|
|
439
|
+
|
|
440
|
+
let previousResult = false;
|
|
441
|
+
|
|
442
|
+
for (const summary of summaries) {
|
|
443
|
+
const checkpoint = await this.checkpointer.load(summary.id);
|
|
444
|
+
if (!checkpoint) {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const currentResult = condition(checkpoint.state as TState);
|
|
449
|
+
|
|
450
|
+
if (currentResult && !previousResult) {
|
|
451
|
+
transitions.push(summary);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
previousResult = currentResult;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return transitions;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Compute differences between two states
|
|
462
|
+
*/
|
|
463
|
+
private diffStates(state1: AgentState, state2: AgentState): StateDiff[] {
|
|
464
|
+
const diffs: StateDiff[] = [];
|
|
465
|
+
|
|
466
|
+
// Compare data fields
|
|
467
|
+
const allKeys = new Set([
|
|
468
|
+
...Object.keys(state1.data),
|
|
469
|
+
...Object.keys(state2.data),
|
|
470
|
+
]);
|
|
471
|
+
|
|
472
|
+
for (const key of allKeys) {
|
|
473
|
+
const val1 = state1.data[key];
|
|
474
|
+
const val2 = state2.data[key];
|
|
475
|
+
|
|
476
|
+
if (JSON.stringify(val1) !== JSON.stringify(val2)) {
|
|
477
|
+
diffs.push({
|
|
478
|
+
path: `data.${key}`,
|
|
479
|
+
type:
|
|
480
|
+
val1 === undefined
|
|
481
|
+
? 'added'
|
|
482
|
+
: val2 === undefined
|
|
483
|
+
? 'removed'
|
|
484
|
+
: 'changed',
|
|
485
|
+
oldValue: val1,
|
|
486
|
+
newValue: val2,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Compare message counts
|
|
492
|
+
if (state1.messages.length !== state2.messages.length) {
|
|
493
|
+
diffs.push({
|
|
494
|
+
path: 'messages.length',
|
|
495
|
+
type: 'changed',
|
|
496
|
+
oldValue: state1.messages.length,
|
|
497
|
+
newValue: state2.messages.length,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Compare current step
|
|
502
|
+
if (state1.currentStep !== state2.currentStep) {
|
|
503
|
+
diffs.push({
|
|
504
|
+
path: 'currentStep',
|
|
505
|
+
type: 'changed',
|
|
506
|
+
oldValue: state1.currentStep,
|
|
507
|
+
newValue: state2.currentStep,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return diffs;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* State difference record
|
|
517
|
+
*/
|
|
518
|
+
export interface StateDiff {
|
|
519
|
+
/** Path to the changed value */
|
|
520
|
+
path: string;
|
|
521
|
+
/** Type of change */
|
|
522
|
+
type: 'added' | 'removed' | 'changed';
|
|
523
|
+
/** Previous value */
|
|
524
|
+
oldValue?: unknown;
|
|
525
|
+
/** New value */
|
|
526
|
+
newValue?: unknown;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* State history item
|
|
531
|
+
*/
|
|
532
|
+
export interface StateHistoryItem<TState extends AgentState = AgentState> {
|
|
533
|
+
/** Checkpoint summary */
|
|
534
|
+
checkpoint: CheckpointSummary;
|
|
535
|
+
/** Full state at this point */
|
|
536
|
+
state: TState;
|
|
537
|
+
/** Changes from previous state */
|
|
538
|
+
changes: StateDiff[];
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Create a checkpoint
|
|
543
|
+
*
|
|
544
|
+
* @example
|
|
545
|
+
* ```typescript
|
|
546
|
+
* const checkpoint = createCheckpoint({
|
|
547
|
+
* executionId: 'exec-123',
|
|
548
|
+
* stepNumber: 5,
|
|
549
|
+
* nodeName: 'process-node',
|
|
550
|
+
* state: currentState
|
|
551
|
+
* });
|
|
552
|
+
*
|
|
553
|
+
* await checkpointer.save(checkpoint);
|
|
554
|
+
* ```
|
|
555
|
+
*
|
|
556
|
+
* @param options - Checkpoint options
|
|
557
|
+
* @returns Checkpoint object
|
|
558
|
+
*/
|
|
559
|
+
export function createCheckpoint<
|
|
560
|
+
TState extends AgentState = AgentState,
|
|
561
|
+
>(options: {
|
|
562
|
+
executionId: string;
|
|
563
|
+
stepNumber: number;
|
|
564
|
+
nodeName: string;
|
|
565
|
+
state: TState;
|
|
566
|
+
parentId?: string;
|
|
567
|
+
metadata?: Record<string, unknown>;
|
|
568
|
+
}): Checkpoint {
|
|
569
|
+
return {
|
|
570
|
+
id: uuidv4(),
|
|
571
|
+
executionId: options.executionId,
|
|
572
|
+
stepNumber: options.stepNumber,
|
|
573
|
+
nodeName: options.nodeName,
|
|
574
|
+
state: options.state,
|
|
575
|
+
timestamp: new Date(),
|
|
576
|
+
parentId: options.parentId,
|
|
577
|
+
metadata: options.metadata,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Checkpoint retention policy
|
|
583
|
+
*/
|
|
584
|
+
export interface RetentionPolicy {
|
|
585
|
+
/** Maximum number of checkpoints to keep per execution */
|
|
586
|
+
maxCheckpointsPerExecution?: number;
|
|
587
|
+
/** Maximum age of checkpoints in milliseconds */
|
|
588
|
+
maxAge?: number;
|
|
589
|
+
/** Keep every Nth checkpoint (for sampling) */
|
|
590
|
+
keepEveryN?: number;
|
|
591
|
+
/** Always keep checkpoints for these nodes */
|
|
592
|
+
alwaysKeepNodes?: string[];
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Apply retention policy to checkpoints
|
|
597
|
+
*
|
|
598
|
+
* @example
|
|
599
|
+
* ```typescript
|
|
600
|
+
* await applyRetentionPolicy(checkpointer, 'exec-123', {
|
|
601
|
+
* maxCheckpointsPerExecution: 100,
|
|
602
|
+
* maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
603
|
+
* keepEveryN: 10,
|
|
604
|
+
* alwaysKeepNodes: ['decision', 'error-handler']
|
|
605
|
+
* });
|
|
606
|
+
* ```
|
|
607
|
+
*
|
|
608
|
+
* @param checkpointer - Checkpointer to clean up
|
|
609
|
+
* @param executionId - Execution to apply policy to
|
|
610
|
+
* @param policy - Retention policy
|
|
611
|
+
* @returns Number of checkpoints deleted
|
|
612
|
+
*/
|
|
613
|
+
export async function applyRetentionPolicy(
|
|
614
|
+
checkpointer: GraphCheckpointer,
|
|
615
|
+
executionId: string,
|
|
616
|
+
policy: RetentionPolicy,
|
|
617
|
+
): Promise<number> {
|
|
618
|
+
const summaries = await checkpointer.list(executionId);
|
|
619
|
+
let deleted = 0;
|
|
620
|
+
|
|
621
|
+
const toDelete: string[] = [];
|
|
622
|
+
const now = Date.now();
|
|
623
|
+
|
|
624
|
+
for (let i = 0; i < summaries.length; i++) {
|
|
625
|
+
const summary = summaries[i];
|
|
626
|
+
if (!summary) {
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Check if node is in always-keep list
|
|
631
|
+
if (policy.alwaysKeepNodes?.includes(summary.nodeName)) {
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Check age
|
|
636
|
+
if (policy.maxAge) {
|
|
637
|
+
const age = now - summary.timestamp.getTime();
|
|
638
|
+
if (age > policy.maxAge) {
|
|
639
|
+
toDelete.push(summary.id);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Check keepEveryN
|
|
645
|
+
if (policy.keepEveryN && (i + 1) % policy.keepEveryN !== 0) {
|
|
646
|
+
// Don't delete the latest few checkpoints
|
|
647
|
+
if (i < summaries.length - 5) {
|
|
648
|
+
toDelete.push(summary.id);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Apply maxCheckpointsPerExecution
|
|
654
|
+
if (policy.maxCheckpointsPerExecution) {
|
|
655
|
+
const remaining = summaries.filter(s => !toDelete.includes(s.id));
|
|
656
|
+
if (remaining.length > policy.maxCheckpointsPerExecution) {
|
|
657
|
+
const excess = remaining.length - policy.maxCheckpointsPerExecution;
|
|
658
|
+
// Delete oldest checkpoints first (but not those in toDelete already)
|
|
659
|
+
for (let i = 0; i < excess && i < remaining.length; i++) {
|
|
660
|
+
const item = remaining[i];
|
|
661
|
+
if (item && !toDelete.includes(item.id)) {
|
|
662
|
+
toDelete.push(item.id);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Delete marked checkpoints
|
|
669
|
+
for (const id of toDelete) {
|
|
670
|
+
await checkpointer.delete(id);
|
|
671
|
+
deleted++;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return deleted;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Schema for checkpoint validation
|
|
679
|
+
*/
|
|
680
|
+
export const CheckpointSchema = z.object({
|
|
681
|
+
id: z.string().uuid(),
|
|
682
|
+
executionId: z.string(),
|
|
683
|
+
stepNumber: z.number().int().min(0),
|
|
684
|
+
nodeName: z.string(),
|
|
685
|
+
timestamp: z.date(),
|
|
686
|
+
parentId: z.string().uuid().optional(),
|
|
687
|
+
metadata: z.record(z.unknown()).optional(),
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Validate a checkpoint
|
|
692
|
+
*/
|
|
693
|
+
export function validateCheckpoint(
|
|
694
|
+
checkpoint: unknown,
|
|
695
|
+
): checkpoint is Checkpoint {
|
|
696
|
+
try {
|
|
697
|
+
CheckpointSchema.parse(checkpoint);
|
|
698
|
+
return true;
|
|
699
|
+
} catch {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
}
|