18 KiB
AGENT-006: State History & Time Travel
Overview
Create a state history tracking system that enables undo/redo, time-travel debugging, and state snapshots. This helps users recover from mistakes and developers debug complex state interactions.
Phase: 3.5 (Real-Time Agentic UI)
Priority: LOW (nice-to-have)
Effort: 1-2 days
Risk: Low
Problem Statement
Current Limitation
State changes are permanent:
User makes mistake → State changes → Can't undo
Developer debugging → State changed 10 steps ago → Can't replay
No way to go back in time.
Desired Pattern
User action → State snapshot → Change state
User: "Undo" → Restore previous snapshot
Developer: "Go back 5 steps" → Time travel to that state
Real-World Use Cases
- Undo Mistakes - User accidentally removes item from timeline
- Debug State - Developer replays sequence that caused bug
- A/B Comparison - Save state, test changes, restore to compare
- Session Recovery - Reload state after browser crash
- Feature Flags - Toggle features on/off with instant rollback
Goals
- ✅ Track state changes automatically
- ✅ Undo/redo state changes
- ✅ Jump to specific state in history
- ✅ Save/restore state snapshots
- ✅ Limit history size (memory management)
- ✅ Integrate with Global Store (AGENT-003)
- ✅ Export/import history (debugging)
Technical Design
Node Specifications
We'll create THREE nodes:
- State History - Track and manage history
- Undo - Revert to previous state
- State Snapshot - Save/restore snapshots
State History Node
{
name: 'net.noodl.StateHistory',
displayNodeName: 'State History',
category: 'Data',
color: 'blue',
docs: 'https://docs.noodl.net/nodes/data/state-history'
}
Ports: State History
| Port Name | Type | Group | Description |
|---|---|---|---|
| Inputs | |||
storeName |
string | Store | Global store to track |
trackKeys |
string | Config | Comma-separated keys to track (blank = all) |
maxHistory |
number | Config | Max history entries (default: 50) |
enabled |
boolean | Config | Enable/disable tracking (default: true) |
clearHistory |
signal | Actions | Clear history |
| Outputs | |||
historySize |
number | Status | Number of entries in history |
canUndo |
boolean | Status | Can go back |
canRedo |
boolean | Status | Can go forward |
currentIndex |
number | Status | Position in history |
history |
array | Data | Full history array |
stateChanged |
signal | Events | Fires on any state change |
Undo Node
{
name: 'net.noodl.StateHistory.Undo',
displayNodeName: 'Undo',
category: 'Data',
color: 'blue'
}
Ports: Undo Node
| Port Name | Type | Group | Description |
|---|---|---|---|
| Inputs | |||
storeName |
string | Store | Store to undo |
undo |
signal | Actions | Go back one step |
redo |
signal | Actions | Go forward one step |
jumpTo |
signal | Actions | Jump to specific index |
targetIndex |
number | Jump | Index to jump to |
| Outputs | |||
undone |
signal | Events | Fires after undo |
redone |
signal | Events | Fires after redo |
jumped |
signal | Events | Fires after jump |
State Snapshot Node
{
name: 'net.noodl.StateSnapshot',
displayNodeName: 'State Snapshot',
category: 'Data',
color: 'blue'
}
Ports: State Snapshot
| Port Name | Type | Group | Description |
|---|---|---|---|
| Inputs | |||
storeName |
string | Store | Store to snapshot |
save |
signal | Actions | Save current state |
restore |
signal | Actions | Restore saved state |
snapshotName |
string | Snapshot | Name for this snapshot |
snapshotData |
object | Snapshot | Snapshot to restore (from export) |
| Outputs | |||
snapshot |
object | Data | Current saved snapshot |
saved |
signal | Events | Fires after save |
restored |
signal | Events | Fires after restore |
Implementation Details
File Structure
packages/noodl-runtime/src/nodes/std-library/data/
├── statehistorymanager.js # History tracking
├── statehistorynode.js # State History node
├── undonode.js # Undo/Redo node
├── statesnapshotnode.js # Snapshot node
└── statehistory.test.js # Tests
State History Manager
// statehistorymanager.js
const { globalStoreManager } = require('./globalstore');
class StateHistoryManager {
constructor() {
this.histories = new Map(); // storeName -> history
this.snapshots = new Map(); // snapshotName -> state
}
/**
* Start tracking a store
*/
trackStore(storeName, options = {}) {
if (this.histories.has(storeName)) {
return; // Already tracking
}
const { maxHistory = 50, trackKeys = [] } = options;
const history = {
entries: [],
currentIndex: -1,
maxHistory,
trackKeys,
unsubscribe: null
};
// Get initial state
const initialState = globalStoreManager.getState(storeName);
history.entries.push({
state: JSON.parse(JSON.stringify(initialState)),
timestamp: Date.now(),
description: 'Initial state'
});
history.currentIndex = 0;
// Subscribe to changes
history.unsubscribe = globalStoreManager.subscribe(
storeName,
(nextState, prevState, changedKeys) => {
this.recordStateChange(storeName, nextState, changedKeys);
},
trackKeys
);
this.histories.set(storeName, history);
console.log(`[StateHistory] Tracking store: ${storeName}`);
}
/**
* Stop tracking a store
*/
stopTracking(storeName) {
const history = this.histories.get(storeName);
if (!history) return;
if (history.unsubscribe) {
history.unsubscribe();
}
this.histories.delete(storeName);
}
/**
* Record a state change
*/
recordStateChange(storeName, newState, changedKeys) {
const history = this.histories.get(storeName);
if (!history) return;
// If we're not at the end, truncate future
if (history.currentIndex < history.entries.length - 1) {
history.entries = history.entries.slice(0, history.currentIndex + 1);
}
// Add new entry
history.entries.push({
state: JSON.parse(JSON.stringify(newState)),
timestamp: Date.now(),
changedKeys: changedKeys,
description: `Changed: ${changedKeys.join(', ')}`
});
// Enforce max history
if (history.entries.length > history.maxHistory) {
history.entries.shift();
} else {
history.currentIndex++;
}
console.log(`[StateHistory] Recorded change in ${storeName}`, changedKeys);
}
/**
* Undo (go back)
*/
undo(storeName) {
const history = this.histories.get(storeName);
if (!history || history.currentIndex <= 0) {
return null; // Can't undo
}
history.currentIndex--;
const entry = history.entries[history.currentIndex];
// Restore state
globalStoreManager.setState(storeName, entry.state);
console.log(`[StateHistory] Undo in ${storeName} to index ${history.currentIndex}`);
return {
state: entry.state,
index: history.currentIndex,
canUndo: history.currentIndex > 0,
canRedo: history.currentIndex < history.entries.length - 1
};
}
/**
* Redo (go forward)
*/
redo(storeName) {
const history = this.histories.get(storeName);
if (!history || history.currentIndex >= history.entries.length - 1) {
return null; // Can't redo
}
history.currentIndex++;
const entry = history.entries[history.currentIndex];
// Restore state
globalStoreManager.setState(storeName, entry.state);
console.log(`[StateHistory] Redo in ${storeName} to index ${history.currentIndex}`);
return {
state: entry.state,
index: history.currentIndex,
canUndo: history.currentIndex > 0,
canRedo: history.currentIndex < history.entries.length - 1
};
}
/**
* Jump to specific point in history
*/
jumpTo(storeName, index) {
const history = this.histories.get(storeName);
if (!history || index < 0 || index >= history.entries.length) {
return null;
}
history.currentIndex = index;
const entry = history.entries[index];
// Restore state
globalStoreManager.setState(storeName, entry.state);
console.log(`[StateHistory] Jump to index ${index} in ${storeName}`);
return {
state: entry.state,
index: history.currentIndex,
canUndo: history.currentIndex > 0,
canRedo: history.currentIndex < history.entries.length - 1
};
}
/**
* Get history info
*/
getHistoryInfo(storeName) {
const history = this.histories.get(storeName);
if (!history) return null;
return {
size: history.entries.length,
currentIndex: history.currentIndex,
canUndo: history.currentIndex > 0,
canRedo: history.currentIndex < history.entries.length - 1,
entries: history.entries.map((e, i) => ({
index: i,
timestamp: e.timestamp,
description: e.description,
isCurrent: i === history.currentIndex
}))
};
}
/**
* Clear history
*/
clearHistory(storeName) {
const history = this.histories.get(storeName);
if (!history) return;
const currentState = globalStoreManager.getState(storeName);
history.entries = [{
state: JSON.parse(JSON.stringify(currentState)),
timestamp: Date.now(),
description: 'Reset'
}];
history.currentIndex = 0;
}
/**
* Save snapshot
*/
saveSnapshot(snapshotName, storeName) {
const state = globalStoreManager.getState(storeName);
const snapshot = {
name: snapshotName,
storeName: storeName,
state: JSON.parse(JSON.stringify(state)),
timestamp: Date.now()
};
this.snapshots.set(snapshotName, snapshot);
console.log(`[StateHistory] Saved snapshot: ${snapshotName}`);
return snapshot;
}
/**
* Restore snapshot
*/
restoreSnapshot(snapshotName) {
const snapshot = this.snapshots.get(snapshotName);
if (!snapshot) {
throw new Error(`Snapshot not found: ${snapshotName}`);
}
globalStoreManager.setState(snapshot.storeName, snapshot.state);
console.log(`[StateHistory] Restored snapshot: ${snapshotName}`);
return snapshot;
}
/**
* Export history (for debugging)
*/
exportHistory(storeName) {
const history = this.histories.get(storeName);
if (!history) return null;
return {
storeName,
entries: history.entries,
currentIndex: history.currentIndex,
exportedAt: Date.now()
};
}
/**
* Import history (for debugging)
*/
importHistory(historyData) {
const { storeName, entries, currentIndex } = historyData;
// Stop current tracking
this.stopTracking(storeName);
// Create new history
const history = {
entries: entries,
currentIndex: currentIndex,
maxHistory: 50,
trackKeys: [],
unsubscribe: null
};
this.histories.set(storeName, history);
// Restore to current index
const entry = entries[currentIndex];
globalStoreManager.setState(storeName, entry.state);
}
}
const stateHistoryManager = new StateHistoryManager();
module.exports = { stateHistoryManager };
State History Node (abbreviated)
// statehistorynode.js
const { stateHistoryManager } = require('./statehistorymanager');
var StateHistoryNode = {
name: 'net.noodl.StateHistory',
displayNodeName: 'State History',
category: 'Data',
color: 'blue',
initialize: function() {
this._internal.tracking = false;
},
inputs: {
storeName: {
type: 'string',
displayName: 'Store Name',
default: 'app',
set: function(value) {
this._internal.storeName = value;
this.startTracking();
}
},
enabled: {
type: 'boolean',
displayName: 'Enabled',
default: true,
set: function(value) {
if (value) {
this.startTracking();
} else {
this.stopTracking();
}
}
},
maxHistory: {
type: 'number',
displayName: 'Max History',
default: 50
},
clearHistory: {
type: 'signal',
displayName: 'Clear History',
valueChangedToTrue: function() {
stateHistoryManager.clearHistory(this._internal.storeName);
this.updateOutputs();
}
}
},
outputs: {
historySize: {
type: 'number',
displayName: 'History Size',
getter: function() {
const info = stateHistoryManager.getHistoryInfo(this._internal.storeName);
return info ? info.size : 0;
}
},
canUndo: {
type: 'boolean',
displayName: 'Can Undo',
getter: function() {
const info = stateHistoryManager.getHistoryInfo(this._internal.storeName);
return info ? info.canUndo : false;
}
},
canRedo: {
type: 'boolean',
displayName: 'Can Redo',
getter: function() {
const info = stateHistoryManager.getHistoryInfo(this._internal.storeName);
return info ? info.canRedo : false;
}
}
},
methods: {
startTracking: function() {
if (this._internal.tracking) return;
stateHistoryManager.trackStore(this._internal.storeName, {
maxHistory: this._internal.maxHistory,
trackKeys: this._internal.trackKeys
});
this._internal.tracking = true;
this.updateOutputs();
},
stopTracking: function() {
if (!this._internal.tracking) return;
stateHistoryManager.stopTracking(this._internal.storeName);
this._internal.tracking = false;
},
updateOutputs: function() {
this.flagOutputDirty('historySize');
this.flagOutputDirty('canUndo');
this.flagOutputDirty('canRedo');
}
}
};
Usage Examples
Example 1: Undo Button
[Button: "Undo"] clicked
↓
[Undo]
storeName: "app"
undo signal
↓
[Undo] undone
→ [Show Toast] "Undone"
[State History] canUndo
→ [Button] disabled = !canUndo
Example 2: Timeline Slider
[State History]
storeName: "app"
historySize → maxValue
currentIndex → value
[Slider] value changed
→ [Undo] targetIndex
→ [Undo] jumpTo signal
// User can scrub through history!
Example 3: Save/Restore Checkpoint
[Button: "Save Checkpoint"] clicked
↓
[State Snapshot]
storeName: "app"
snapshotName: "checkpoint-1"
save
// Later...
[Button: "Restore Checkpoint"] clicked
↓
[State Snapshot]
snapshotName: "checkpoint-1"
restore
Example 4: Debug Mode
// Dev tools panel
[State History] history
→ [Repeater] show each entry
[Entry] clicked
→ [Undo] jumpTo with entry.index
[Button: "Export History"]
→ [State History] exportHistory
→ [File Download] history.json
Testing Checklist
Functional Tests
- History tracks state changes
- Undo reverts to previous state
- Redo goes forward
- Jump to specific index works
- Max history limit enforced
- Clear history works
- Snapshots save/restore correctly
- Export/import preserves history
Edge Cases
- Undo at beginning (no-op)
- Redo at end (no-op)
- Jump to invalid index
- Change state while not at end (truncate future)
- Track empty store
- Very rapid state changes
- Large state objects (>1MB)
Performance
- No memory leaks with long history
- History doesn't slow down app
- Deep cloning doesn't block UI
Documentation Requirements
User-Facing Docs
Create: docs/nodes/data/state-history.md
# State History
Add undo/redo and time travel to your app. Track state changes and let users go back in time.
## Use Cases
- **Undo Mistakes**: User accidentally deletes something
- **Debug Complex State**: Developer traces bug through history
- **A/B Testing**: Save state, test, restore to compare
- **Session Recovery**: Reload after crash
## Basic Usage
**Step 1: Track State**
[State History] storeName: "app" maxHistory: 50
**Step 2: Add Undo**
[Button: "Undo"] clicked → [Undo] storeName: "app", undo signal
**Step 3: Disable When Can't Undo**
[State History] canUndo → [Button] disabled = !canUndo
## Time Travel
Build a history slider:
[State History] history → entries → [Slider] 0 to historySize → value changed → [Undo] jumpTo
## Snapshots
Save points you can return to:
[State Snapshot] save → checkpoint [State Snapshot] restore ← checkpoint
## Best Practices
1. **Limit history size**: 50 entries prevents memory issues
2. **Track only what you need**: Use trackKeys for large stores
3. **Disable in production**: Enable only for dev/debug
Success Criteria
- ✅ Undo/redo works reliably
- ✅ History doesn't leak memory
- ✅ Snapshots save/restore correctly
- ✅ Export/import for debugging
- ✅ Clear documentation
- ✅ Optional enhancement for Erleah
Future Enhancements
- Diff Viewer - Show what changed between states
- Branching History - Tree instead of linear
- Selective Undo - Undo specific changes only
- Persistence - Save history to localStorage
- Collaborative Undo - Undo others' changes
Dependencies
- AGENT-003 (Global State Store)
Blocked By
- AGENT-003
Blocks
- None (optional feature)
Estimated Effort Breakdown
| Phase | Estimate | Description |
|---|---|---|
| History Manager | 0.5 day | Core tracking system |
| Undo/Redo Node | 0.5 day | Node implementation |
| Snapshot Node | 0.5 day | Save/restore system |
| Testing | 0.5 day | Edge cases, memory leaks |
| Documentation | 0.5 day | User docs, examples |
Total: 2.5 days
Buffer: None needed
Final: 1-2 days (if scope kept minimal)