@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.
@@ -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
+ }