diff --git a/.aios-core/core/execution/time-travel.js b/.aios-core/core/execution/time-travel.js new file mode 100644 index 0000000000..d4b8dfe62b --- /dev/null +++ b/.aios-core/core/execution/time-travel.js @@ -0,0 +1,742 @@ +/** + * Execution Time Travel + * Story EXE-4 - Checkpoint, replay, fork and rewind agent executions + * + * Provides "git for agent sessions" — timeline-based checkpoint, replay, + * fork and rewind capabilities for agent execution state. + * + * @module aiox-core/execution/time-travel + * @version 1.0.0 + * @story EXE-4 - Execution Time Travel + */ + +const EventEmitter = require('events'); +const fs = require('fs'); +const path = require('path'); + +/** + * Timeline statuses + * @enum {string} + */ +const TimelineStatus = { + ACTIVE: 'active', + ARCHIVED: 'archived', +}; + +/** + * Checkpoint statuses + * @enum {string} + */ +const CheckpointStatus = { + ACTIVE: 'active', + REWOUND: 'rewound', +}; + +let idCounter = 0; + +/** + * Generate a unique ID with prefix + * @param {string} prefix - ID prefix + * @returns {string} Unique ID + */ +function generateId(prefix) { + idCounter += 1; + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 8); + return `${prefix}_${timestamp}${random}${idCounter}`; +} + +/** + * Deep clone a value using structured clone or JSON fallback + * @param {*} value - Value to clone + * @returns {*} Deep cloned value + */ +function deepClone(value) { + if (value === null || value === undefined) return value; + if (typeof structuredClone === 'function') return structuredClone(value); + return JSON.parse(JSON.stringify(value)); +} + +/** + * Execution Time Travel Engine + * + * Manages timeline-based execution history with checkpoint, replay, + * fork and rewind capabilities. Each agent session can have multiple + * timelines, and timelines can be forked at any checkpoint. + * + * @class TimeTravelEngine + * @extends EventEmitter + */ +class TimeTravelEngine extends EventEmitter { + /** + * Create a new TimeTravelEngine + * @param {Object} [config={}] - Configuration options + * @param {string} [config.storageDir='.aiox/timelines'] - Directory for timeline persistence + * @param {number} [config.maxCheckpointsPerTimeline=500] - Max checkpoints per timeline + * @param {boolean} [config.autoPersist=true] - Auto-persist timelines to disk + */ + constructor(config = {}) { + super(); + + this.storageDir = config.storageDir ?? '.aiox/timelines'; + this.maxCheckpointsPerTimeline = config.maxCheckpointsPerTimeline ?? 500; + this.autoPersist = config.autoPersist ?? true; + + /** @type {Map} */ + this.timelines = new Map(); + + this._loaded = false; + + this._stats = { + timelinesCreated: 0, + checkpointsCreated: 0, + forksCreated: 0, + rewindsPerformed: 0, + restoresPerformed: 0, + }; + + // Sync load persisted timelines on startup + if (this.autoPersist) { + this._loadFromDiskSync(); + } + } + + // --------------------------------------------------------------------------- + // createTimeline + // --------------------------------------------------------------------------- + + /** + * Create a new timeline for a session + * @param {string} sessionId - Session identifier + * @param {Object} [metadata={}] - Additional metadata + * @returns {Promise} Created timeline + */ + async createTimeline(sessionId, metadata = {}) { + if (!sessionId) { + throw new Error('sessionId is required'); + } + + const id = generateId('tl'); + const now = new Date().toISOString(); + + const timeline = { + id, + sessionId, + parentId: null, + parentCheckpointId: null, + metadata: deepClone(metadata), + checkpoints: [], + forks: [], + createdAt: now, + updatedAt: now, + status: TimelineStatus.ACTIVE, + }; + + this.timelines.set(id, timeline); + this._stats.timelinesCreated += 1; + + this.emit('timeline:created', { + timelineId: id, + sessionId, + metadata: deepClone(metadata), + }); + + await this._persist(timeline); + + return deepClone(timeline); + } + + // --------------------------------------------------------------------------- + // checkpoint + // --------------------------------------------------------------------------- + + /** + * Save a checkpoint on a timeline + * @param {string} timelineId - Timeline ID + * @param {*} state - State to checkpoint (will be deep-cloned) + * @param {string} [label=''] - Human-readable label + * @returns {Promise} Created checkpoint + */ + async checkpoint(timelineId, state, label = '') { + const timeline = this._getTimeline(timelineId); + + if (timeline.checkpoints.length >= this.maxCheckpointsPerTimeline) { + throw new Error( + `Timeline has reached maximum of ${this.maxCheckpointsPerTimeline} checkpoints` + ); + } + + const id = generateId('cp'); + const checkpoint = { + id, + timelineId, + state: deepClone(state), + label: label ?? '', + timestamp: new Date().toISOString(), + index: timeline.checkpoints.length, + status: CheckpointStatus.ACTIVE, + }; + + timeline.checkpoints.push(checkpoint); + timeline.updatedAt = new Date().toISOString(); + this._stats.checkpointsCreated += 1; + + this.emit('checkpoint:created', { + timelineId, + checkpointId: id, + label: checkpoint.label, + index: checkpoint.index, + }); + + await this._persist(timeline); + + return deepClone(checkpoint); + } + + // --------------------------------------------------------------------------- + // restoreCheckpoint + // --------------------------------------------------------------------------- + + /** + * Restore state from a checkpoint + * @param {string} timelineId - Timeline ID + * @param {string} checkpointId - Checkpoint ID + * @returns {Promise} Restored checkpoint data + */ + async restoreCheckpoint(timelineId, checkpointId) { + const timeline = this._getTimeline(timelineId); + const checkpoint = this._getCheckpoint(timeline, checkpointId); + + this._stats.restoresPerformed += 1; + + this.emit('checkpoint:restored', { + timelineId, + checkpointId, + label: checkpoint.label, + index: checkpoint.index, + }); + + return { + checkpointId: checkpoint.id, + state: deepClone(checkpoint.state), + label: checkpoint.label, + index: checkpoint.index, + timestamp: checkpoint.timestamp, + }; + } + + // --------------------------------------------------------------------------- + // fork + // --------------------------------------------------------------------------- + + /** + * Fork a timeline from a specific checkpoint + * @param {string} timelineId - Source timeline ID + * @param {string} checkpointId - Checkpoint to fork from + * @param {Object} [metadata={}] - Fork metadata + * @returns {Promise} New forked timeline + */ + async fork(timelineId, checkpointId, metadata = {}) { + const sourceTimeline = this._getTimeline(timelineId); + const checkpoint = this._getCheckpoint(sourceTimeline, checkpointId); + + const cpIndex = sourceTimeline.checkpoints.indexOf(checkpoint); + const copiedCheckpoints = sourceTimeline.checkpoints + .slice(0, cpIndex + 1) + .map((cp) => deepClone(cp)); + + const forkId = generateId('tl'); + const now = new Date().toISOString(); + + // Update copied checkpoints to reference the new timeline + copiedCheckpoints.forEach((cp) => { + cp.timelineId = forkId; + }); + + const forkTimeline = { + id: forkId, + sessionId: sourceTimeline.sessionId, + parentId: timelineId, + parentCheckpointId: checkpointId, + metadata: deepClone(metadata), + checkpoints: copiedCheckpoints, + forks: [], + createdAt: now, + updatedAt: now, + status: TimelineStatus.ACTIVE, + }; + + this.timelines.set(forkId, forkTimeline); + + // Register fork on source timeline + sourceTimeline.forks.push({ + timelineId: forkId, + checkpointId, + createdAt: now, + }); + sourceTimeline.updatedAt = now; + + this._stats.timelinesCreated += 1; + this._stats.forksCreated += 1; + + this.emit('timeline:forked', { + sourceTimelineId: timelineId, + forkTimelineId: forkId, + checkpointId, + metadata: deepClone(metadata), + }); + + await this._persist(sourceTimeline); + await this._persist(forkTimeline); + + return deepClone(forkTimeline); + } + + // --------------------------------------------------------------------------- + // rewind + // --------------------------------------------------------------------------- + + /** + * Rewind a timeline to a previous checkpoint + * Marks all checkpoints after the target as 'rewound' + * @param {string} timelineId - Timeline ID + * @param {string} checkpointId - Target checkpoint ID + * @returns {Promise} Rewind result + */ + async rewind(timelineId, checkpointId) { + const timeline = this._getTimeline(timelineId); + const checkpoint = this._getCheckpoint(timeline, checkpointId); + + const cpIndex = timeline.checkpoints.indexOf(checkpoint); + const rewoundCheckpoints = []; + + for (let i = cpIndex + 1; i < timeline.checkpoints.length; i++) { + const cp = timeline.checkpoints[i]; + if (cp.status === CheckpointStatus.ACTIVE) { + cp.status = CheckpointStatus.REWOUND; + rewoundCheckpoints.push(cp.id); + } + } + + timeline.updatedAt = new Date().toISOString(); + this._stats.rewindsPerformed += 1; + + this.emit('timeline:rewound', { + timelineId, + checkpointId, + rewoundCheckpoints, + }); + + await this._persist(timeline); + + return { + timelineId, + checkpointId, + state: deepClone(checkpoint.state), + rewoundCheckpoints, + }; + } + + // --------------------------------------------------------------------------- + // getReplayPlan + // --------------------------------------------------------------------------- + + /** + * Get a plan to replay between two checkpoints + * @param {string} timelineId - Timeline ID + * @param {string} fromCheckpointId - Starting checkpoint ID + * @param {string} toCheckpointId - Ending checkpoint ID + * @returns {Object} Replay plan with steps + */ + getReplayPlan(timelineId, fromCheckpointId, toCheckpointId) { + const timeline = this._getTimeline(timelineId); + const fromCp = this._getCheckpoint(timeline, fromCheckpointId); + const toCp = this._getCheckpoint(timeline, toCheckpointId); + + const fromIndex = timeline.checkpoints.indexOf(fromCp); + const toIndex = timeline.checkpoints.indexOf(toCp); + + if (fromIndex >= toIndex) { + throw new Error('fromCheckpoint must precede toCheckpoint in the timeline'); + } + + const steps = timeline.checkpoints.slice(fromIndex + 1, toIndex + 1).map((cp) => ({ + checkpointId: cp.id, + label: cp.label, + index: cp.index, + status: cp.status, + timestamp: cp.timestamp, + })); + + return { + timelineId, + from: { + checkpointId: fromCp.id, + label: fromCp.label, + index: fromCp.index, + }, + to: { + checkpointId: toCp.id, + label: toCp.label, + index: toCp.index, + }, + totalSteps: steps.length, + steps, + }; + } + + // --------------------------------------------------------------------------- + // compareTimelines + // --------------------------------------------------------------------------- + + /** + * Compare two timelines (useful for forks) + * @param {string} timelineId1 - First timeline ID + * @param {string} timelineId2 - Second timeline ID + * @returns {Object} Comparison result + */ + compareTimelines(timelineId1, timelineId2) { + const tl1 = this._getTimeline(timelineId1); + const tl2 = this._getTimeline(timelineId2); + + // Find shared checkpoints using lineage (parent relationship) + const sharedCheckpoints = []; + let commonAncestorIndex = -1; + let forkPointIndex = 0; + + // Check if tl2 is a fork of tl1 (or vice versa) + if (tl2.parentId === timelineId1) { + // tl2 was forked from tl1 — use parentCheckpointId to find fork point + const forkCpId = tl2.parentCheckpointId; + const forkCpIdx = tl1.checkpoints.findIndex((cp) => cp.id === forkCpId); + + if (forkCpIdx >= 0) { + for (let i = 0; i <= forkCpIdx; i++) { + sharedCheckpoints.push({ + index: i, + label: tl1.checkpoints[i].label, + state: deepClone(tl1.checkpoints[i].state), + }); + commonAncestorIndex = i; + } + forkPointIndex = forkCpIdx + 1; + } + } else if (tl1.parentId === timelineId2) { + // tl1 was forked from tl2 — use parentCheckpointId to find fork point + const forkCpId = tl1.parentCheckpointId; + const forkCpIdx = tl2.checkpoints.findIndex((cp) => cp.id === forkCpId); + + if (forkCpIdx >= 0) { + for (let i = 0; i <= forkCpIdx; i++) { + sharedCheckpoints.push({ + index: i, + label: tl2.checkpoints[i].label, + state: deepClone(tl2.checkpoints[i].state), + }); + commonAncestorIndex = i; + } + forkPointIndex = forkCpIdx + 1; + } + } + // For unrelated timelines, sharedCheckpoints stays empty + + // Divergent checkpoints + const onlyInTimeline1 = tl1.checkpoints.slice(forkPointIndex).map((cp) => ({ + checkpointId: cp.id, + label: cp.label, + index: cp.index, + })); + + const onlyInTimeline2 = tl2.checkpoints.slice(sharedCheckpoints.length).map((cp) => ({ + checkpointId: cp.id, + label: cp.label, + index: cp.index, + })); + + return { + timeline1: { + id: tl1.id, + sessionId: tl1.sessionId, + totalCheckpoints: tl1.checkpoints.length, + }, + timeline2: { + id: tl2.id, + sessionId: tl2.sessionId, + totalCheckpoints: tl2.checkpoints.length, + }, + sharedCheckpoints, + commonAncestorIndex, + divergentCheckpoints: { + onlyInTimeline1, + onlyInTimeline2, + }, + }; + } + + // --------------------------------------------------------------------------- + // getTimelineTree + // --------------------------------------------------------------------------- + + /** + * Get the full tree of a timeline and its forks + * @param {string} timelineId - Root timeline ID + * @returns {Object} Tree structure + */ + getTimelineTree(timelineId) { + const timeline = this._getTimeline(timelineId); + + const activeCheckpointCount = timeline.checkpoints.filter( + (cp) => cp.status === CheckpointStatus.ACTIVE + ).length; + + const children = timeline.forks + .map((fork) => { + if (this.timelines.has(fork.timelineId)) { + return this.getTimelineTree(fork.timelineId); + } + return null; + }) + .filter(Boolean); + + return { + id: timeline.id, + sessionId: timeline.sessionId, + parentId: timeline.parentId, + status: timeline.status, + checkpointCount: timeline.checkpoints.length, + activeCheckpointCount, + forkCount: timeline.forks.length, + children, + createdAt: timeline.createdAt, + }; + } + + // --------------------------------------------------------------------------- + // listTimelines + // --------------------------------------------------------------------------- + + /** + * List all timelines with optional filtering + * @param {Object} [filter={}] - Filter options + * @param {string} [filter.sessionId] - Filter by session ID + * @param {string} [filter.status] - Filter by status + * @param {string} [filter.parentId] - Filter by parent ID + * @returns {Promise} Filtered timeline list + */ + async listTimelines(filter = {}) { + // If no timelines loaded, try to load from disk + if (this.timelines.size === 0) { + await this._loadFromDisk(); + } + + let timelines = Array.from(this.timelines.values()); + + if (filter.sessionId) { + timelines = timelines.filter((tl) => tl.sessionId === filter.sessionId); + } + + if (filter.status) { + timelines = timelines.filter((tl) => tl.status === filter.status); + } + + if (filter.parentId) { + timelines = timelines.filter((tl) => tl.parentId === filter.parentId); + } + + return timelines.map((tl) => deepClone(tl)); + } + + // --------------------------------------------------------------------------- + // deleteTimeline + // --------------------------------------------------------------------------- + + /** + * Delete a timeline + * @param {string} timelineId - Timeline ID to delete + * @param {Object} [options={}] - Delete options + * @param {boolean} [options.deleteForks=false] - Also delete all forks + * @returns {Promise} Deletion result + */ + async deleteTimeline(timelineId, options = {}) { + const timeline = this._getTimeline(timelineId); + const deleted = []; + + // Recursively delete forks if requested + if (options.deleteForks) { + for (const fork of timeline.forks) { + if (this.timelines.has(fork.timelineId)) { + const result = await this.deleteTimeline(fork.timelineId, { deleteForks: true }); + deleted.push(...result.deleted); + } + } + } + + // Remove fork reference from parent + if (timeline.parentId) { + const parent = this.timelines.get(timeline.parentId); + if (parent) { + parent.forks = parent.forks.filter((f) => f.timelineId !== timelineId); + parent.updatedAt = new Date().toISOString(); + await this._persist(parent); + } + } + + // Remove from memory + this.timelines.delete(timelineId); + deleted.push(timelineId); + + // Remove from disk + try { + const filePath = path.join(this.storageDir, `${timelineId}.json`); + await fs.promises.unlink(filePath); + } catch (_err) { + // File may not exist, that's fine + } + + return { deleted, count: deleted.length }; + } + + // --------------------------------------------------------------------------- + // getStats + // --------------------------------------------------------------------------- + + /** + * Get engine statistics + * @returns {Object} Statistics + */ + getStats() { + let totalCheckpoints = 0; + let activeTimelines = 0; + + for (const tl of this.timelines.values()) { + totalCheckpoints += tl.checkpoints.length; + if (tl.status === TimelineStatus.ACTIVE) { + activeTimelines += 1; + } + } + + return { + ...this._stats, + totalTimelines: this.timelines.size, + activeTimelines, + totalCheckpoints, + }; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Get a timeline by ID or throw + * @private + * @param {string} timelineId - Timeline ID + * @returns {Object} Timeline object + */ + _getTimeline(timelineId) { + const timeline = this.timelines.get(timelineId); + if (!timeline) { + throw new Error(`Timeline not found: ${timelineId}`); + } + return timeline; + } + + /** + * Get a checkpoint from a timeline or throw + * @private + * @param {Object} timeline - Timeline object + * @param {string} checkpointId - Checkpoint ID + * @returns {Object} Checkpoint object + */ + _getCheckpoint(timeline, checkpointId) { + const checkpoint = timeline.checkpoints.find((cp) => cp.id === checkpointId); + if (!checkpoint) { + throw new Error(`Checkpoint not found: ${checkpointId}`); + } + return checkpoint; + } + + /** + * Persist a timeline to disk + * @private + * @param {Object} timeline - Timeline to persist + */ + async _persist(timeline) { + if (!this.autoPersist) return; + + try { + await fs.promises.mkdir(this.storageDir, { recursive: true }); + const filePath = path.join(this.storageDir, `${timeline.id}.json`); + await fs.promises.writeFile(filePath, JSON.stringify(timeline, null, 2), 'utf-8'); + } catch (error) { + if (this.listenerCount('error') > 0) { + this.emit('error', { + operation: 'persist', + timelineId: timeline.id, + error: error.message, + }); + } + } + } + + /** + * Load all timelines from disk (sync, used by constructor) + * @private + */ + _loadFromDiskSync() { + if (this._loaded) return; + try { + const files = fs.readdirSync(this.storageDir); + const jsonFiles = files.filter((f) => f.endsWith('.json')); + + for (const file of jsonFiles) { + try { + const filePath = path.join(this.storageDir, file); + const data = fs.readFileSync(filePath, 'utf-8'); + const timeline = JSON.parse(data); + if (timeline.id) { + this.timelines.set(timeline.id, timeline); + } + } catch (_err) { + // Skip corrupt files + } + } + } catch (_err) { + // Directory may not exist yet + } + this._loaded = true; + } + + /** + * Load all timelines from disk + * @private + */ + async _loadFromDisk() { + if (this._loaded) return; + try { + const files = await fs.promises.readdir(this.storageDir); + const jsonFiles = files.filter((f) => f.endsWith('.json')); + + for (const file of jsonFiles) { + try { + const filePath = path.join(this.storageDir, file); + const data = await fs.promises.readFile(filePath, 'utf-8'); + const timeline = JSON.parse(data); + if (timeline.id) { + this.timelines.set(timeline.id, timeline); + } + } catch (_err) { + // Skip corrupt files + } + } + } catch (_err) { + // Directory may not exist yet + } + this._loaded = true; + } +} + +module.exports = TimeTravelEngine; +module.exports.TimeTravelEngine = TimeTravelEngine; +module.exports.TimelineStatus = TimelineStatus; +module.exports.CheckpointStatus = CheckpointStatus; diff --git a/.aiox-core/core/execution/time-travel.js b/.aiox-core/core/execution/time-travel.js new file mode 100644 index 0000000000..d4b8dfe62b --- /dev/null +++ b/.aiox-core/core/execution/time-travel.js @@ -0,0 +1,742 @@ +/** + * Execution Time Travel + * Story EXE-4 - Checkpoint, replay, fork and rewind agent executions + * + * Provides "git for agent sessions" — timeline-based checkpoint, replay, + * fork and rewind capabilities for agent execution state. + * + * @module aiox-core/execution/time-travel + * @version 1.0.0 + * @story EXE-4 - Execution Time Travel + */ + +const EventEmitter = require('events'); +const fs = require('fs'); +const path = require('path'); + +/** + * Timeline statuses + * @enum {string} + */ +const TimelineStatus = { + ACTIVE: 'active', + ARCHIVED: 'archived', +}; + +/** + * Checkpoint statuses + * @enum {string} + */ +const CheckpointStatus = { + ACTIVE: 'active', + REWOUND: 'rewound', +}; + +let idCounter = 0; + +/** + * Generate a unique ID with prefix + * @param {string} prefix - ID prefix + * @returns {string} Unique ID + */ +function generateId(prefix) { + idCounter += 1; + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 8); + return `${prefix}_${timestamp}${random}${idCounter}`; +} + +/** + * Deep clone a value using structured clone or JSON fallback + * @param {*} value - Value to clone + * @returns {*} Deep cloned value + */ +function deepClone(value) { + if (value === null || value === undefined) return value; + if (typeof structuredClone === 'function') return structuredClone(value); + return JSON.parse(JSON.stringify(value)); +} + +/** + * Execution Time Travel Engine + * + * Manages timeline-based execution history with checkpoint, replay, + * fork and rewind capabilities. Each agent session can have multiple + * timelines, and timelines can be forked at any checkpoint. + * + * @class TimeTravelEngine + * @extends EventEmitter + */ +class TimeTravelEngine extends EventEmitter { + /** + * Create a new TimeTravelEngine + * @param {Object} [config={}] - Configuration options + * @param {string} [config.storageDir='.aiox/timelines'] - Directory for timeline persistence + * @param {number} [config.maxCheckpointsPerTimeline=500] - Max checkpoints per timeline + * @param {boolean} [config.autoPersist=true] - Auto-persist timelines to disk + */ + constructor(config = {}) { + super(); + + this.storageDir = config.storageDir ?? '.aiox/timelines'; + this.maxCheckpointsPerTimeline = config.maxCheckpointsPerTimeline ?? 500; + this.autoPersist = config.autoPersist ?? true; + + /** @type {Map} */ + this.timelines = new Map(); + + this._loaded = false; + + this._stats = { + timelinesCreated: 0, + checkpointsCreated: 0, + forksCreated: 0, + rewindsPerformed: 0, + restoresPerformed: 0, + }; + + // Sync load persisted timelines on startup + if (this.autoPersist) { + this._loadFromDiskSync(); + } + } + + // --------------------------------------------------------------------------- + // createTimeline + // --------------------------------------------------------------------------- + + /** + * Create a new timeline for a session + * @param {string} sessionId - Session identifier + * @param {Object} [metadata={}] - Additional metadata + * @returns {Promise} Created timeline + */ + async createTimeline(sessionId, metadata = {}) { + if (!sessionId) { + throw new Error('sessionId is required'); + } + + const id = generateId('tl'); + const now = new Date().toISOString(); + + const timeline = { + id, + sessionId, + parentId: null, + parentCheckpointId: null, + metadata: deepClone(metadata), + checkpoints: [], + forks: [], + createdAt: now, + updatedAt: now, + status: TimelineStatus.ACTIVE, + }; + + this.timelines.set(id, timeline); + this._stats.timelinesCreated += 1; + + this.emit('timeline:created', { + timelineId: id, + sessionId, + metadata: deepClone(metadata), + }); + + await this._persist(timeline); + + return deepClone(timeline); + } + + // --------------------------------------------------------------------------- + // checkpoint + // --------------------------------------------------------------------------- + + /** + * Save a checkpoint on a timeline + * @param {string} timelineId - Timeline ID + * @param {*} state - State to checkpoint (will be deep-cloned) + * @param {string} [label=''] - Human-readable label + * @returns {Promise} Created checkpoint + */ + async checkpoint(timelineId, state, label = '') { + const timeline = this._getTimeline(timelineId); + + if (timeline.checkpoints.length >= this.maxCheckpointsPerTimeline) { + throw new Error( + `Timeline has reached maximum of ${this.maxCheckpointsPerTimeline} checkpoints` + ); + } + + const id = generateId('cp'); + const checkpoint = { + id, + timelineId, + state: deepClone(state), + label: label ?? '', + timestamp: new Date().toISOString(), + index: timeline.checkpoints.length, + status: CheckpointStatus.ACTIVE, + }; + + timeline.checkpoints.push(checkpoint); + timeline.updatedAt = new Date().toISOString(); + this._stats.checkpointsCreated += 1; + + this.emit('checkpoint:created', { + timelineId, + checkpointId: id, + label: checkpoint.label, + index: checkpoint.index, + }); + + await this._persist(timeline); + + return deepClone(checkpoint); + } + + // --------------------------------------------------------------------------- + // restoreCheckpoint + // --------------------------------------------------------------------------- + + /** + * Restore state from a checkpoint + * @param {string} timelineId - Timeline ID + * @param {string} checkpointId - Checkpoint ID + * @returns {Promise} Restored checkpoint data + */ + async restoreCheckpoint(timelineId, checkpointId) { + const timeline = this._getTimeline(timelineId); + const checkpoint = this._getCheckpoint(timeline, checkpointId); + + this._stats.restoresPerformed += 1; + + this.emit('checkpoint:restored', { + timelineId, + checkpointId, + label: checkpoint.label, + index: checkpoint.index, + }); + + return { + checkpointId: checkpoint.id, + state: deepClone(checkpoint.state), + label: checkpoint.label, + index: checkpoint.index, + timestamp: checkpoint.timestamp, + }; + } + + // --------------------------------------------------------------------------- + // fork + // --------------------------------------------------------------------------- + + /** + * Fork a timeline from a specific checkpoint + * @param {string} timelineId - Source timeline ID + * @param {string} checkpointId - Checkpoint to fork from + * @param {Object} [metadata={}] - Fork metadata + * @returns {Promise} New forked timeline + */ + async fork(timelineId, checkpointId, metadata = {}) { + const sourceTimeline = this._getTimeline(timelineId); + const checkpoint = this._getCheckpoint(sourceTimeline, checkpointId); + + const cpIndex = sourceTimeline.checkpoints.indexOf(checkpoint); + const copiedCheckpoints = sourceTimeline.checkpoints + .slice(0, cpIndex + 1) + .map((cp) => deepClone(cp)); + + const forkId = generateId('tl'); + const now = new Date().toISOString(); + + // Update copied checkpoints to reference the new timeline + copiedCheckpoints.forEach((cp) => { + cp.timelineId = forkId; + }); + + const forkTimeline = { + id: forkId, + sessionId: sourceTimeline.sessionId, + parentId: timelineId, + parentCheckpointId: checkpointId, + metadata: deepClone(metadata), + checkpoints: copiedCheckpoints, + forks: [], + createdAt: now, + updatedAt: now, + status: TimelineStatus.ACTIVE, + }; + + this.timelines.set(forkId, forkTimeline); + + // Register fork on source timeline + sourceTimeline.forks.push({ + timelineId: forkId, + checkpointId, + createdAt: now, + }); + sourceTimeline.updatedAt = now; + + this._stats.timelinesCreated += 1; + this._stats.forksCreated += 1; + + this.emit('timeline:forked', { + sourceTimelineId: timelineId, + forkTimelineId: forkId, + checkpointId, + metadata: deepClone(metadata), + }); + + await this._persist(sourceTimeline); + await this._persist(forkTimeline); + + return deepClone(forkTimeline); + } + + // --------------------------------------------------------------------------- + // rewind + // --------------------------------------------------------------------------- + + /** + * Rewind a timeline to a previous checkpoint + * Marks all checkpoints after the target as 'rewound' + * @param {string} timelineId - Timeline ID + * @param {string} checkpointId - Target checkpoint ID + * @returns {Promise} Rewind result + */ + async rewind(timelineId, checkpointId) { + const timeline = this._getTimeline(timelineId); + const checkpoint = this._getCheckpoint(timeline, checkpointId); + + const cpIndex = timeline.checkpoints.indexOf(checkpoint); + const rewoundCheckpoints = []; + + for (let i = cpIndex + 1; i < timeline.checkpoints.length; i++) { + const cp = timeline.checkpoints[i]; + if (cp.status === CheckpointStatus.ACTIVE) { + cp.status = CheckpointStatus.REWOUND; + rewoundCheckpoints.push(cp.id); + } + } + + timeline.updatedAt = new Date().toISOString(); + this._stats.rewindsPerformed += 1; + + this.emit('timeline:rewound', { + timelineId, + checkpointId, + rewoundCheckpoints, + }); + + await this._persist(timeline); + + return { + timelineId, + checkpointId, + state: deepClone(checkpoint.state), + rewoundCheckpoints, + }; + } + + // --------------------------------------------------------------------------- + // getReplayPlan + // --------------------------------------------------------------------------- + + /** + * Get a plan to replay between two checkpoints + * @param {string} timelineId - Timeline ID + * @param {string} fromCheckpointId - Starting checkpoint ID + * @param {string} toCheckpointId - Ending checkpoint ID + * @returns {Object} Replay plan with steps + */ + getReplayPlan(timelineId, fromCheckpointId, toCheckpointId) { + const timeline = this._getTimeline(timelineId); + const fromCp = this._getCheckpoint(timeline, fromCheckpointId); + const toCp = this._getCheckpoint(timeline, toCheckpointId); + + const fromIndex = timeline.checkpoints.indexOf(fromCp); + const toIndex = timeline.checkpoints.indexOf(toCp); + + if (fromIndex >= toIndex) { + throw new Error('fromCheckpoint must precede toCheckpoint in the timeline'); + } + + const steps = timeline.checkpoints.slice(fromIndex + 1, toIndex + 1).map((cp) => ({ + checkpointId: cp.id, + label: cp.label, + index: cp.index, + status: cp.status, + timestamp: cp.timestamp, + })); + + return { + timelineId, + from: { + checkpointId: fromCp.id, + label: fromCp.label, + index: fromCp.index, + }, + to: { + checkpointId: toCp.id, + label: toCp.label, + index: toCp.index, + }, + totalSteps: steps.length, + steps, + }; + } + + // --------------------------------------------------------------------------- + // compareTimelines + // --------------------------------------------------------------------------- + + /** + * Compare two timelines (useful for forks) + * @param {string} timelineId1 - First timeline ID + * @param {string} timelineId2 - Second timeline ID + * @returns {Object} Comparison result + */ + compareTimelines(timelineId1, timelineId2) { + const tl1 = this._getTimeline(timelineId1); + const tl2 = this._getTimeline(timelineId2); + + // Find shared checkpoints using lineage (parent relationship) + const sharedCheckpoints = []; + let commonAncestorIndex = -1; + let forkPointIndex = 0; + + // Check if tl2 is a fork of tl1 (or vice versa) + if (tl2.parentId === timelineId1) { + // tl2 was forked from tl1 — use parentCheckpointId to find fork point + const forkCpId = tl2.parentCheckpointId; + const forkCpIdx = tl1.checkpoints.findIndex((cp) => cp.id === forkCpId); + + if (forkCpIdx >= 0) { + for (let i = 0; i <= forkCpIdx; i++) { + sharedCheckpoints.push({ + index: i, + label: tl1.checkpoints[i].label, + state: deepClone(tl1.checkpoints[i].state), + }); + commonAncestorIndex = i; + } + forkPointIndex = forkCpIdx + 1; + } + } else if (tl1.parentId === timelineId2) { + // tl1 was forked from tl2 — use parentCheckpointId to find fork point + const forkCpId = tl1.parentCheckpointId; + const forkCpIdx = tl2.checkpoints.findIndex((cp) => cp.id === forkCpId); + + if (forkCpIdx >= 0) { + for (let i = 0; i <= forkCpIdx; i++) { + sharedCheckpoints.push({ + index: i, + label: tl2.checkpoints[i].label, + state: deepClone(tl2.checkpoints[i].state), + }); + commonAncestorIndex = i; + } + forkPointIndex = forkCpIdx + 1; + } + } + // For unrelated timelines, sharedCheckpoints stays empty + + // Divergent checkpoints + const onlyInTimeline1 = tl1.checkpoints.slice(forkPointIndex).map((cp) => ({ + checkpointId: cp.id, + label: cp.label, + index: cp.index, + })); + + const onlyInTimeline2 = tl2.checkpoints.slice(sharedCheckpoints.length).map((cp) => ({ + checkpointId: cp.id, + label: cp.label, + index: cp.index, + })); + + return { + timeline1: { + id: tl1.id, + sessionId: tl1.sessionId, + totalCheckpoints: tl1.checkpoints.length, + }, + timeline2: { + id: tl2.id, + sessionId: tl2.sessionId, + totalCheckpoints: tl2.checkpoints.length, + }, + sharedCheckpoints, + commonAncestorIndex, + divergentCheckpoints: { + onlyInTimeline1, + onlyInTimeline2, + }, + }; + } + + // --------------------------------------------------------------------------- + // getTimelineTree + // --------------------------------------------------------------------------- + + /** + * Get the full tree of a timeline and its forks + * @param {string} timelineId - Root timeline ID + * @returns {Object} Tree structure + */ + getTimelineTree(timelineId) { + const timeline = this._getTimeline(timelineId); + + const activeCheckpointCount = timeline.checkpoints.filter( + (cp) => cp.status === CheckpointStatus.ACTIVE + ).length; + + const children = timeline.forks + .map((fork) => { + if (this.timelines.has(fork.timelineId)) { + return this.getTimelineTree(fork.timelineId); + } + return null; + }) + .filter(Boolean); + + return { + id: timeline.id, + sessionId: timeline.sessionId, + parentId: timeline.parentId, + status: timeline.status, + checkpointCount: timeline.checkpoints.length, + activeCheckpointCount, + forkCount: timeline.forks.length, + children, + createdAt: timeline.createdAt, + }; + } + + // --------------------------------------------------------------------------- + // listTimelines + // --------------------------------------------------------------------------- + + /** + * List all timelines with optional filtering + * @param {Object} [filter={}] - Filter options + * @param {string} [filter.sessionId] - Filter by session ID + * @param {string} [filter.status] - Filter by status + * @param {string} [filter.parentId] - Filter by parent ID + * @returns {Promise} Filtered timeline list + */ + async listTimelines(filter = {}) { + // If no timelines loaded, try to load from disk + if (this.timelines.size === 0) { + await this._loadFromDisk(); + } + + let timelines = Array.from(this.timelines.values()); + + if (filter.sessionId) { + timelines = timelines.filter((tl) => tl.sessionId === filter.sessionId); + } + + if (filter.status) { + timelines = timelines.filter((tl) => tl.status === filter.status); + } + + if (filter.parentId) { + timelines = timelines.filter((tl) => tl.parentId === filter.parentId); + } + + return timelines.map((tl) => deepClone(tl)); + } + + // --------------------------------------------------------------------------- + // deleteTimeline + // --------------------------------------------------------------------------- + + /** + * Delete a timeline + * @param {string} timelineId - Timeline ID to delete + * @param {Object} [options={}] - Delete options + * @param {boolean} [options.deleteForks=false] - Also delete all forks + * @returns {Promise} Deletion result + */ + async deleteTimeline(timelineId, options = {}) { + const timeline = this._getTimeline(timelineId); + const deleted = []; + + // Recursively delete forks if requested + if (options.deleteForks) { + for (const fork of timeline.forks) { + if (this.timelines.has(fork.timelineId)) { + const result = await this.deleteTimeline(fork.timelineId, { deleteForks: true }); + deleted.push(...result.deleted); + } + } + } + + // Remove fork reference from parent + if (timeline.parentId) { + const parent = this.timelines.get(timeline.parentId); + if (parent) { + parent.forks = parent.forks.filter((f) => f.timelineId !== timelineId); + parent.updatedAt = new Date().toISOString(); + await this._persist(parent); + } + } + + // Remove from memory + this.timelines.delete(timelineId); + deleted.push(timelineId); + + // Remove from disk + try { + const filePath = path.join(this.storageDir, `${timelineId}.json`); + await fs.promises.unlink(filePath); + } catch (_err) { + // File may not exist, that's fine + } + + return { deleted, count: deleted.length }; + } + + // --------------------------------------------------------------------------- + // getStats + // --------------------------------------------------------------------------- + + /** + * Get engine statistics + * @returns {Object} Statistics + */ + getStats() { + let totalCheckpoints = 0; + let activeTimelines = 0; + + for (const tl of this.timelines.values()) { + totalCheckpoints += tl.checkpoints.length; + if (tl.status === TimelineStatus.ACTIVE) { + activeTimelines += 1; + } + } + + return { + ...this._stats, + totalTimelines: this.timelines.size, + activeTimelines, + totalCheckpoints, + }; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Get a timeline by ID or throw + * @private + * @param {string} timelineId - Timeline ID + * @returns {Object} Timeline object + */ + _getTimeline(timelineId) { + const timeline = this.timelines.get(timelineId); + if (!timeline) { + throw new Error(`Timeline not found: ${timelineId}`); + } + return timeline; + } + + /** + * Get a checkpoint from a timeline or throw + * @private + * @param {Object} timeline - Timeline object + * @param {string} checkpointId - Checkpoint ID + * @returns {Object} Checkpoint object + */ + _getCheckpoint(timeline, checkpointId) { + const checkpoint = timeline.checkpoints.find((cp) => cp.id === checkpointId); + if (!checkpoint) { + throw new Error(`Checkpoint not found: ${checkpointId}`); + } + return checkpoint; + } + + /** + * Persist a timeline to disk + * @private + * @param {Object} timeline - Timeline to persist + */ + async _persist(timeline) { + if (!this.autoPersist) return; + + try { + await fs.promises.mkdir(this.storageDir, { recursive: true }); + const filePath = path.join(this.storageDir, `${timeline.id}.json`); + await fs.promises.writeFile(filePath, JSON.stringify(timeline, null, 2), 'utf-8'); + } catch (error) { + if (this.listenerCount('error') > 0) { + this.emit('error', { + operation: 'persist', + timelineId: timeline.id, + error: error.message, + }); + } + } + } + + /** + * Load all timelines from disk (sync, used by constructor) + * @private + */ + _loadFromDiskSync() { + if (this._loaded) return; + try { + const files = fs.readdirSync(this.storageDir); + const jsonFiles = files.filter((f) => f.endsWith('.json')); + + for (const file of jsonFiles) { + try { + const filePath = path.join(this.storageDir, file); + const data = fs.readFileSync(filePath, 'utf-8'); + const timeline = JSON.parse(data); + if (timeline.id) { + this.timelines.set(timeline.id, timeline); + } + } catch (_err) { + // Skip corrupt files + } + } + } catch (_err) { + // Directory may not exist yet + } + this._loaded = true; + } + + /** + * Load all timelines from disk + * @private + */ + async _loadFromDisk() { + if (this._loaded) return; + try { + const files = await fs.promises.readdir(this.storageDir); + const jsonFiles = files.filter((f) => f.endsWith('.json')); + + for (const file of jsonFiles) { + try { + const filePath = path.join(this.storageDir, file); + const data = await fs.promises.readFile(filePath, 'utf-8'); + const timeline = JSON.parse(data); + if (timeline.id) { + this.timelines.set(timeline.id, timeline); + } + } catch (_err) { + // Skip corrupt files + } + } + } catch (_err) { + // Directory may not exist yet + } + this._loaded = true; + } +} + +module.exports = TimeTravelEngine; +module.exports.TimeTravelEngine = TimeTravelEngine; +module.exports.TimelineStatus = TimelineStatus; +module.exports.CheckpointStatus = CheckpointStatus; diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 2516e4c299..f3dad51bc2 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,9 +8,9 @@ # - File types for categorization # version: 5.0.3 -generated_at: "2026-03-10T17:08:07.160Z" +generated_at: "2026-03-11T02:25:38.874Z" generator: scripts/generate-install-manifest.js -file_count: 1089 +file_count: 1090 files: - path: cli/commands/config/index.js hash: sha256:25c4b9bf4e0241abf7754b55153f49f1a214f1fb5fe904a576675634cb7b3da9 @@ -436,6 +436,10 @@ files: hash: sha256:7affbc04de9be2bc53427670009a885f0b35e1cc183f82c2e044abf9611344b6 type: core size: 25738 + - path: core/execution/time-travel.js + hash: sha256:eb133cf60cb26c7771c07bfbc4809872d688f94abdbee995e14a7d4efc379da0 + type: core + size: 21615 - path: core/execution/wave-executor.js hash: sha256:4e2324edb37ae0729062b5ac029f2891e050e7efd3a48d0f4a1dc4f227a6716b type: core @@ -1221,9 +1225,9 @@ files: type: data size: 9575 - path: data/entity-registry.yaml - hash: sha256:151aca46769c614f0238e7a8e2968884c3888b438ff1c634f0eeb2505e325b83 + hash: sha256:c2b44be42b65aa41e7cf5bd749b75c9356334c5f81b8d5b1defe0816e3b5540a type: data - size: 521804 + size: 522289 - path: data/learned-patterns.yaml hash: sha256:24ac0b160615583a0ff783d3da8af80b7f94191575d6db2054ec8e10a3f945dc type: data @@ -2583,19 +2587,19 @@ files: - path: development/templates/service-template/__tests__/index.test.ts.hbs hash: sha256:4617c189e75ab362d4ef2cabcc3ccce3480f914fd915af550469c17d1b68a4fe type: template - size: 9810 + size: 9573 - path: development/templates/service-template/client.ts.hbs hash: sha256:f342c60695fe611192002bdb8c04b3a0dbce6345b7fa39834ea1898f71689198 type: template - size: 12213 + size: 11810 - path: development/templates/service-template/errors.ts.hbs hash: sha256:e0be40d8be19b71b26e35778eadffb20198e7ca88e9d140db9da1bfe12de01ec type: template - size: 5395 + size: 5213 - path: development/templates/service-template/index.ts.hbs hash: sha256:d44012d54b76ab98356c7163d257ca939f7fed122f10fecf896fe1e7e206d10a type: template - size: 3206 + size: 3086 - path: development/templates/service-template/jest.config.js hash: sha256:1681bfd7fbc0d330d3487d3427515847c4d57ef300833f573af59e0ad69ed159 type: template @@ -2603,11 +2607,11 @@ files: - path: development/templates/service-template/package.json.hbs hash: sha256:d89d35f56992ee95c2ceddf17fa1d455c18007a4d24af914ba83cf4abc38bca9 type: template - size: 2314 + size: 2227 - path: development/templates/service-template/README.md.hbs hash: sha256:2c3dd4c2bf6df56b9b6db439977be7e1cc35820438c0e023140eccf6ccd227a0 type: template - size: 3584 + size: 3426 - path: development/templates/service-template/tsconfig.json hash: sha256:8b465fcbdd45c4d6821ba99aea62f2bd7998b1bca8de80486a1525e77d43c9a1 type: template @@ -2615,7 +2619,7 @@ files: - path: development/templates/service-template/types.ts.hbs hash: sha256:3e52e0195003be8cd1225a3f27f4d040686c8b8c7762f71b41055f04cd1b841b type: template - size: 2661 + size: 2516 - path: development/templates/squad-template/agents/example-agent.yaml hash: sha256:824a1b349965e5d4ae85458c231b78260dc65497da75dada25b271f2cabbbe67 type: agent @@ -2623,7 +2627,7 @@ files: - path: development/templates/squad-template/LICENSE hash: sha256:ff7017aa403270cf2c440f5ccb4240d0b08e54d8bf8a0424d34166e8f3e10138 type: template - size: 1092 + size: 1071 - path: development/templates/squad-template/package.json hash: sha256:8f68627a0d74e49f94ae382d0c2b56ecb5889d00f3095966c742fb5afaf363db type: template @@ -3367,11 +3371,11 @@ files: - path: infrastructure/templates/aiox-sync.yaml.template hash: sha256:0040ad8a9e25716a28631b102c9448b72fd72e84f992c3926eb97e9e514744bb type: template - size: 8567 + size: 8385 - path: infrastructure/templates/coderabbit.yaml.template hash: sha256:91a4a76bbc40767a4072fb6a87c480902bb800cfb0a11e9fc1b3183d8f7f3a80 type: template - size: 8321 + size: 8042 - path: infrastructure/templates/core-config/core-config-brownfield.tmpl.yaml hash: sha256:9bdb0c0e09c765c991f9f142921f7f8e2c0d0ada717f41254161465dc0622d02 type: template @@ -3383,11 +3387,11 @@ files: - path: infrastructure/templates/github-workflows/ci.yml.template hash: sha256:acbfa2a8a84141fd6a6b205eac74719772f01c221c0afe22ce951356f06a605d type: template - size: 5089 + size: 4920 - path: infrastructure/templates/github-workflows/pr-automation.yml.template hash: sha256:c236077b4567965a917e48df9a91cc42153ff97b00a9021c41a7e28179be9d0f type: template - size: 10939 + size: 10609 - path: infrastructure/templates/github-workflows/README.md hash: sha256:6b7b5cb32c28b3e562c81a96e2573ea61849b138c93ccac6e93c3adac26cadb5 type: template @@ -3395,23 +3399,23 @@ files: - path: infrastructure/templates/github-workflows/release.yml.template hash: sha256:b771145e61a254a88dc6cca07869e4ece8229ce18be87132f59489cdf9a66ec6 type: template - size: 6791 + size: 6595 - path: infrastructure/templates/gitignore/gitignore-aiox-base.tmpl hash: sha256:9088975ee2bf4d88e23db6ac3ea5d27cccdc72b03db44450300e2f872b02e935 type: template - size: 851 + size: 788 - path: infrastructure/templates/gitignore/gitignore-brownfield-merge.tmpl hash: sha256:ce4291a3cf5677050c9dafa320809e6b0ca5db7e7f7da0382d2396e32016a989 type: template - size: 506 + size: 488 - path: infrastructure/templates/gitignore/gitignore-node.tmpl hash: sha256:5179f78de7483274f5d7182569229088c71934db1fd37a63a40b3c6b815c9c8e type: template - size: 1036 + size: 951 - path: infrastructure/templates/gitignore/gitignore-python.tmpl hash: sha256:d7aac0b1e6e340b774a372a9102b4379722588449ca82ac468cf77804bbc1e55 type: template - size: 1725 + size: 1580 - path: infrastructure/templates/project-docs/coding-standards-tmpl.md hash: sha256:377acf85463df8ac9923fc59d7cfeba68a82f8353b99948ea1d28688e88bc4a9 type: template @@ -3507,43 +3511,43 @@ files: - path: monitor/hooks/lib/__init__.py hash: sha256:bfab6ee249c52f412c02502479da649b69d044938acaa6ab0aa39dafe6dee9bf type: monitor - size: 30 + size: 29 - path: monitor/hooks/lib/enrich.py hash: sha256:20dfa73b4b20d7a767e52c3ec90919709c4447c6e230902ba797833fc6ddc22c type: monitor - size: 1702 + size: 1644 - path: monitor/hooks/lib/send_event.py hash: sha256:59d61311f718fb373a5cf85fd7a01c23a4fd727e8e022ad6930bba533ef4615d type: monitor - size: 1237 + size: 1190 - path: monitor/hooks/notification.py hash: sha256:8a1a6ce0ff2b542014de177006093b9caec9b594e938a343dc6bd62df2504f22 type: monitor - size: 528 + size: 499 - path: monitor/hooks/post_tool_use.py hash: sha256:47dbe37073d432c55657647fc5b907ddb56efa859d5c3205e8362aa916d55434 type: monitor - size: 1185 + size: 1140 - path: monitor/hooks/pre_compact.py hash: sha256:f287cf45e83deed6f1bc0e30bd9348dfa1bf08ad770c5e58bb34e3feb210b30b type: monitor - size: 529 + size: 500 - path: monitor/hooks/pre_tool_use.py hash: sha256:a4d1d3ffdae9349e26a383c67c9137effff7d164ac45b2c87eea9fa1ab0d6d98 type: monitor - size: 1021 + size: 981 - path: monitor/hooks/stop.py hash: sha256:edb382f0cf46281a11a8588bc20eafa7aa2b5cc3f4ad775d71b3d20a7cfab385 type: monitor - size: 519 + size: 490 - path: monitor/hooks/subagent_stop.py hash: sha256:fa5357309247c71551dba0a19f28dd09bebde749db033d6657203b50929c0a42 type: monitor - size: 541 + size: 512 - path: monitor/hooks/user_prompt_submit.py hash: sha256:af57dca79ef55cdf274432f4abb4c20a9778b95e107ca148f47ace14782c5828 type: monitor - size: 856 + size: 818 - path: package.json hash: sha256:9fdf0dcee2dcec6c0643634ee384ba181ad077dcff1267d8807434d4cb4809c7 type: other @@ -3691,7 +3695,7 @@ files: - path: product/templates/adr.hbs hash: sha256:d68653cae9e64414ad4f58ea941b6c6e337c5324c2c7247043eca1461a652d10 type: template - size: 2337 + size: 2212 - path: product/templates/agent-template.yaml hash: sha256:98676fcc493c0d5f09264dcc52fcc2cf1129f9a195824ecb4c2ec035c2515121 type: template @@ -3743,7 +3747,7 @@ files: - path: product/templates/dbdr.hbs hash: sha256:5a2781ffaa3da9fc663667b5a63a70b7edfc478ed14cad02fc6ed237ff216315 type: template - size: 4380 + size: 4139 - path: product/templates/design-story-tmpl.yaml hash: sha256:2bfefc11ae2bcfc679dbd924c58f8b764fa23538c14cb25344d6edef41968f29 type: template @@ -3807,7 +3811,7 @@ files: - path: product/templates/epic.hbs hash: sha256:dcbcc26f6dd8f3782b3ef17aee049b689f1d6d92931615c3df9513eca0de2ef7 type: template - size: 4080 + size: 3868 - path: product/templates/eslintrc-security.json hash: sha256:657d40117261d6a52083984d29f9f88e79040926a64aa4c2058a602bfe91e0d5 type: template @@ -3915,7 +3919,7 @@ files: - path: product/templates/pmdr.hbs hash: sha256:d529cebbb562faa82c70477ece70de7cda871eaa6896f2962b48b2a8b67b1cbe type: template - size: 3425 + size: 3239 - path: product/templates/prd-tmpl.yaml hash: sha256:25c239f40e05f24aee1986601a98865188dbe3ea00a705028efc3adad6d420f3 type: template @@ -3923,11 +3927,11 @@ files: - path: product/templates/prd-v2.0.hbs hash: sha256:21a20ef5333a85a11f5326d35714e7939b51bab22bd6e28d49bacab755763bea type: template - size: 4728 + size: 4512 - path: product/templates/prd.hbs hash: sha256:4a1a030a5388c6a8bf2ce6ea85e54cae6cf1fe64f1bb2af7f17d349d3c24bf1d type: template - size: 3626 + size: 3425 - path: product/templates/project-brief-tmpl.yaml hash: sha256:b8d388268c24dc5018f48a87036d591b11cb122fafe9b59c17809b06ea5d9d58 type: template @@ -3975,7 +3979,7 @@ files: - path: product/templates/story.hbs hash: sha256:3f0ac8b39907634a2b53f43079afc33663eee76f46e680d318ff253e0befc2c4 type: template - size: 5846 + size: 5583 - path: product/templates/task-execution-report.md hash: sha256:e0f08a3e199234f3d2207ba8f435786b7d8e1b36174f46cb82fc3666b9a9309e type: template @@ -3987,67 +3991,67 @@ files: - path: product/templates/task.hbs hash: sha256:621e987e142c455cd290dc85d990ab860faa0221f66cf1f57ac296b076889ea5 type: template - size: 2875 + size: 2705 - path: product/templates/tmpl-comment-on-examples.sql hash: sha256:254002c3fbc63cfcc5848b1d4b15822ce240bf5f57e6a1c8bb984e797edc2691 type: template - size: 6373 + size: 6215 - path: product/templates/tmpl-migration-script.sql hash: sha256:44ef63ea475526d21a11e3c667c9fdb78a9fddace80fdbaa2312b7f2724fbbb5 type: template - size: 3038 + size: 2947 - path: product/templates/tmpl-rls-granular-policies.sql hash: sha256:36c2fd8c6d9eebb5d164acb0fb0c87bc384d389264b4429ce21e77e06318f5f3 type: template - size: 3426 + size: 3322 - path: product/templates/tmpl-rls-kiss-policy.sql hash: sha256:5210d37fce62e5a9a00e8d5366f5f75653cd518be73fbf96333ed8a6712453c7 type: template - size: 309 + size: 299 - path: product/templates/tmpl-rls-roles.sql hash: sha256:2d032a608a8e87440c3a430c7d69ddf9393d8813d8d4129270f640dd847425c3 type: template - size: 4727 + size: 4592 - path: product/templates/tmpl-rls-simple.sql hash: sha256:f67af0fa1cdd2f2af9eab31575ac3656d82457421208fd9ccb8b57ca9785275e type: template - size: 2992 + size: 2915 - path: product/templates/tmpl-rls-tenant.sql hash: sha256:36629ed87a2c72311809cc3fb96298b6f38716bba35bc56c550ac39d3321757a type: template - size: 5130 + size: 4978 - path: product/templates/tmpl-rollback-script.sql hash: sha256:8b84046a98f1163faf7350322f43831447617c5a63a94c88c1a71b49804e022b type: template - size: 2734 + size: 2657 - path: product/templates/tmpl-seed-data.sql hash: sha256:a65e73298f46cd6a8e700f29b9d8d26e769e12a57751a943a63fd0fe15768615 type: template - size: 5716 + size: 5576 - path: product/templates/tmpl-smoke-test.sql hash: sha256:aee7e48bb6d9c093769dee215cacc9769939501914e20e5ea8435b25fad10f3c type: template - size: 739 + size: 723 - path: product/templates/tmpl-staging-copy-merge.sql hash: sha256:55988caeb47cc04261665ba7a37f4caa2aa5fac2e776fdbc5964e0587af24450 type: template - size: 4220 + size: 4081 - path: product/templates/tmpl-stored-proc.sql hash: sha256:2b205ff99dc0adfade6047a4d79f5b50109e50ceb45386e5c886437692c7a2a3 type: template - size: 3979 + size: 3839 - path: product/templates/tmpl-trigger.sql hash: sha256:93abdc92e1b475d1370094e69a9d1b18afd804da6acb768b878355c798bd8e0e type: template - size: 5424 + size: 5272 - path: product/templates/tmpl-view-materialized.sql hash: sha256:47935510f03d4ad9b2200748e65441ce6c2d6a7c74750395eca6831d77c48e91 type: template - size: 4496 + size: 4363 - path: product/templates/tmpl-view.sql hash: sha256:22557b076003a856b32397f05fa44245a126521de907058a95e14dd02da67aff type: template - size: 5093 + size: 4916 - path: product/templates/token-exports-css-tmpl.css hash: sha256:d937b8d61cdc9e5b10fdff871c6cb41c9f756004d060d671e0ae26624a047f62 type: template diff --git a/tests/core/execution/time-travel.test.js b/tests/core/execution/time-travel.test.js new file mode 100644 index 0000000000..83bf07741f --- /dev/null +++ b/tests/core/execution/time-travel.test.js @@ -0,0 +1,1041 @@ +/** + * Execution Time Travel Tests + * Story EXE-4 - Checkpoint, replay, fork and rewind agent executions + */ + +const { + TimeTravelEngine, + TimelineStatus, + CheckpointStatus, +} = require('../../../.aiox-core/core/execution/time-travel'); + +// Mock fs module for persistence tests +jest.mock('fs', () => { + const actualFs = jest.requireActual('fs'); + const store = new Map(); + + const mockPromises = { + mkdir: jest.fn().mockResolvedValue(undefined), + writeFile: jest.fn().mockImplementation((filePath, data) => { + store.set(filePath, data); + return Promise.resolve(); + }), + readFile: jest.fn().mockImplementation((filePath) => { + if (store.has(filePath)) { + return Promise.resolve(store.get(filePath)); + } + return Promise.reject(new Error('ENOENT: no such file')); + }), + readdir: jest.fn().mockImplementation(() => { + const files = []; + for (const key of store.keys()) { + const parts = key.split('/'); + files.push(parts[parts.length - 1]); + } + return Promise.resolve(files); + }), + unlink: jest.fn().mockImplementation((filePath) => { + store.delete(filePath); + return Promise.resolve(); + }), + }; + + // Expose store for test inspection + mockPromises._store = store; + mockPromises._clearStore = () => store.clear(); + + // Sync mocks for _loadFromDiskSync + const readdirSync = jest.fn().mockImplementation((dir) => { + const files = []; + for (const key of store.keys()) { + const parts = key.split('/'); + files.push(parts[parts.length - 1]); + } + return files; + }); + + const readFileSync = jest.fn().mockImplementation((filePath) => { + if (store.has(filePath)) { + return store.get(filePath); + } + throw new Error('ENOENT: no such file'); + }); + + return { + ...actualFs, + promises: mockPromises, + readdirSync, + readFileSync, + }; +}); + +const fs = require('fs').promises; + +describe('TimeTravelEngine', () => { + let engine; + + beforeEach(() => { + fs._clearStore(); + engine = new TimeTravelEngine({ + storageDir: '/tmp/test-timelines', + autoPersist: true, + }); + }); + + // --------------------------------------------------------------------------- + // Constants + // --------------------------------------------------------------------------- + + describe('constants', () => { + it('should export TimelineStatus with correct values', () => { + expect(TimelineStatus.ACTIVE).toBe('active'); + expect(TimelineStatus.ARCHIVED).toBe('archived'); + }); + + it('should export CheckpointStatus with correct values', () => { + expect(CheckpointStatus.ACTIVE).toBe('active'); + expect(CheckpointStatus.REWOUND).toBe('rewound'); + }); + }); + + // --------------------------------------------------------------------------- + // Constructor + // --------------------------------------------------------------------------- + + describe('constructor', () => { + it('should use default config values', () => { + const defaultEngine = new TimeTravelEngine(); + expect(defaultEngine.storageDir).toBe('.aiox/timelines'); + expect(defaultEngine.maxCheckpointsPerTimeline).toBe(500); + expect(defaultEngine.autoPersist).toBe(true); + }); + + it('should accept custom config with nullish coalescing', () => { + const custom = new TimeTravelEngine({ + storageDir: '/custom/path', + maxCheckpointsPerTimeline: 100, + autoPersist: false, + }); + expect(custom.storageDir).toBe('/custom/path'); + expect(custom.maxCheckpointsPerTimeline).toBe(100); + expect(custom.autoPersist).toBe(false); + }); + + it('should initialize empty timelines map', () => { + expect(engine.timelines.size).toBe(0); + }); + + it('should initialize stats to zero', () => { + const stats = engine.getStats(); + expect(stats.timelinesCreated).toBe(0); + expect(stats.checkpointsCreated).toBe(0); + expect(stats.forksCreated).toBe(0); + expect(stats.rewindsPerformed).toBe(0); + expect(stats.restoresPerformed).toBe(0); + }); + + it('should be an EventEmitter', () => { + expect(typeof engine.on).toBe('function'); + expect(typeof engine.emit).toBe('function'); + }); + }); + + // --------------------------------------------------------------------------- + // createTimeline + // --------------------------------------------------------------------------- + + describe('createTimeline', () => { + it('should create a timeline with correct structure', async () => { + const tl = await engine.createTimeline('session-1', { agent: 'dev' }); + + expect(tl.id).toMatch(/^tl_/); + expect(tl.sessionId).toBe('session-1'); + expect(tl.parentId).toBeNull(); + expect(tl.parentCheckpointId).toBeNull(); + expect(tl.metadata).toEqual({ agent: 'dev' }); + expect(tl.checkpoints).toEqual([]); + expect(tl.forks).toEqual([]); + expect(tl.status).toBe(TimelineStatus.ACTIVE); + expect(tl.createdAt).toBeDefined(); + expect(tl.updatedAt).toBeDefined(); + }); + + it('should throw if sessionId is missing', async () => { + await expect(engine.createTimeline('')).rejects.toThrow('sessionId is required'); + await expect(engine.createTimeline(null)).rejects.toThrow('sessionId is required'); + await expect(engine.createTimeline(undefined)).rejects.toThrow('sessionId is required'); + }); + + it('should emit timeline:created event', async () => { + const handler = jest.fn(); + engine.on('timeline:created', handler); + + const tl = await engine.createTimeline('session-1', { agent: 'dev' }); + + expect(handler).toHaveBeenCalledWith({ + timelineId: tl.id, + sessionId: 'session-1', + metadata: { agent: 'dev' }, + }); + }); + + it('should increment timelinesCreated stat', async () => { + await engine.createTimeline('s1'); + await engine.createTimeline('s2'); + + expect(engine.getStats().timelinesCreated).toBe(2); + }); + + it('should persist timeline to disk', async () => { + await engine.createTimeline('session-1'); + + expect(fs.mkdir).toHaveBeenCalledWith('/tmp/test-timelines', { recursive: true }); + expect(fs.writeFile).toHaveBeenCalled(); + }); + + it('should deep-clone metadata so mutations do not leak', async () => { + const meta = { tags: ['important'] }; + const tl = await engine.createTimeline('s1', meta); + + meta.tags.push('mutated'); + const stored = engine.timelines.get(tl.id); + expect(stored.metadata.tags).toEqual(['important']); + }); + + it('should create multiple timelines with unique IDs', async () => { + const tl1 = await engine.createTimeline('s1'); + const tl2 = await engine.createTimeline('s1'); + + expect(tl1.id).not.toBe(tl2.id); + }); + }); + + // --------------------------------------------------------------------------- + // checkpoint + // --------------------------------------------------------------------------- + + describe('checkpoint', () => { + let timeline; + + beforeEach(async () => { + timeline = await engine.createTimeline('session-1'); + }); + + it('should create a checkpoint with correct structure', async () => { + const cp = await engine.checkpoint(timeline.id, { step: 1, data: 'hello' }, 'First step'); + + expect(cp.id).toMatch(/^cp_/); + expect(cp.timelineId).toBe(timeline.id); + expect(cp.state).toEqual({ step: 1, data: 'hello' }); + expect(cp.label).toBe('First step'); + expect(cp.index).toBe(0); + expect(cp.status).toBe(CheckpointStatus.ACTIVE); + expect(cp.timestamp).toBeDefined(); + }); + + it('should assign sequential indices', async () => { + const cp1 = await engine.checkpoint(timeline.id, { v: 1 }, 'cp1'); + const cp2 = await engine.checkpoint(timeline.id, { v: 2 }, 'cp2'); + const cp3 = await engine.checkpoint(timeline.id, { v: 3 }, 'cp3'); + + expect(cp1.index).toBe(0); + expect(cp2.index).toBe(1); + expect(cp3.index).toBe(2); + }); + + it('should deep-clone state so mutations do not leak', async () => { + const state = { items: [1, 2, 3] }; + await engine.checkpoint(timeline.id, state, 'test'); + + state.items.push(999); + const stored = engine.timelines.get(timeline.id).checkpoints[0]; + expect(stored.state.items).toEqual([1, 2, 3]); + }); + + it('should emit checkpoint:created event', async () => { + const handler = jest.fn(); + engine.on('checkpoint:created', handler); + + const cp = await engine.checkpoint(timeline.id, { x: 1 }, 'label'); + + expect(handler).toHaveBeenCalledWith({ + timelineId: timeline.id, + checkpointId: cp.id, + label: 'label', + index: 0, + }); + }); + + it('should increment checkpointsCreated stat', async () => { + await engine.checkpoint(timeline.id, { a: 1 }); + await engine.checkpoint(timeline.id, { a: 2 }); + + expect(engine.getStats().checkpointsCreated).toBe(2); + }); + + it('should throw if timeline does not exist', async () => { + await expect(engine.checkpoint('nonexistent', {})).rejects.toThrow('Timeline not found'); + }); + + it('should enforce maximum checkpoints per timeline', async () => { + const small = new TimeTravelEngine({ + maxCheckpointsPerTimeline: 3, + autoPersist: false, + }); + const tl = await small.createTimeline('s1'); + + await small.checkpoint(tl.id, { v: 1 }); + await small.checkpoint(tl.id, { v: 2 }); + await small.checkpoint(tl.id, { v: 3 }); + + await expect(small.checkpoint(tl.id, { v: 4 })).rejects.toThrow( + 'maximum of 3 checkpoints' + ); + }); + + it('should default label to empty string', async () => { + const cp = await engine.checkpoint(timeline.id, { x: 1 }); + expect(cp.label).toBe(''); + }); + }); + + // --------------------------------------------------------------------------- + // restoreCheckpoint + // --------------------------------------------------------------------------- + + describe('restoreCheckpoint', () => { + it('should return the full state at the checkpoint', async () => { + const tl = await engine.createTimeline('s1'); + const cp = await engine.checkpoint(tl.id, { model: 'gpt-4', tokens: 500 }, 'snapshot'); + + const restored = await engine.restoreCheckpoint(tl.id, cp.id); + + expect(restored.state).toEqual({ model: 'gpt-4', tokens: 500 }); + expect(restored.checkpointId).toBe(cp.id); + expect(restored.label).toBe('snapshot'); + expect(restored.index).toBe(0); + }); + + it('should emit checkpoint:restored event', async () => { + const handler = jest.fn(); + engine.on('checkpoint:restored', handler); + + const tl = await engine.createTimeline('s1'); + const cp = await engine.checkpoint(tl.id, { v: 1 }, 'test'); + + await engine.restoreCheckpoint(tl.id, cp.id); + + expect(handler).toHaveBeenCalledWith({ + timelineId: tl.id, + checkpointId: cp.id, + label: 'test', + index: 0, + }); + }); + + it('should increment restoresPerformed stat', async () => { + const tl = await engine.createTimeline('s1'); + const cp = await engine.checkpoint(tl.id, { v: 1 }); + + await engine.restoreCheckpoint(tl.id, cp.id); + + expect(engine.getStats().restoresPerformed).toBe(1); + }); + + it('should throw if checkpoint does not exist', async () => { + const tl = await engine.createTimeline('s1'); + + await expect(engine.restoreCheckpoint(tl.id, 'cp_nonexistent')).rejects.toThrow( + 'Checkpoint not found' + ); + }); + + it('should return a deep clone so caller cannot mutate internal state', async () => { + const tl = await engine.createTimeline('s1'); + const cp = await engine.checkpoint(tl.id, { items: [1, 2] }); + + const restored = await engine.restoreCheckpoint(tl.id, cp.id); + restored.state.items.push(999); + + const restoredAgain = await engine.restoreCheckpoint(tl.id, cp.id); + expect(restoredAgain.state.items).toEqual([1, 2]); + }); + }); + + // --------------------------------------------------------------------------- + // fork + // --------------------------------------------------------------------------- + + describe('fork', () => { + let tl; + let cp1; + let cp2; + + beforeEach(async () => { + tl = await engine.createTimeline('session-1', { agent: 'main' }); + cp1 = await engine.checkpoint(tl.id, { step: 1 }, 'step-1'); + cp2 = await engine.checkpoint(tl.id, { step: 2 }, 'step-2'); + }); + + it('should create a fork timeline with parent reference', async () => { + const fork = await engine.fork(tl.id, cp1.id, { branch: 'experiment' }); + + expect(fork.id).toMatch(/^tl_/); + expect(fork.parentId).toBe(tl.id); + expect(fork.parentCheckpointId).toBe(cp1.id); + expect(fork.sessionId).toBe('session-1'); + expect(fork.metadata).toEqual({ branch: 'experiment' }); + expect(fork.status).toBe(TimelineStatus.ACTIVE); + }); + + it('should copy checkpoints up to and including the fork point', async () => { + const fork = await engine.fork(tl.id, cp1.id); + + // cp1 is at index 0, so 1 checkpoint should be copied + expect(fork.checkpoints).toHaveLength(1); + expect(fork.checkpoints[0].state).toEqual({ step: 1 }); + expect(fork.checkpoints[0].timelineId).toBe(fork.id); + }); + + it('should register the fork on the source timeline', async () => { + const fork = await engine.fork(tl.id, cp2.id); + + const source = engine.timelines.get(tl.id); + expect(source.forks).toHaveLength(1); + expect(source.forks[0].timelineId).toBe(fork.id); + expect(source.forks[0].checkpointId).toBe(cp2.id); + }); + + it('should emit timeline:forked event', async () => { + const handler = jest.fn(); + engine.on('timeline:forked', handler); + + const fork = await engine.fork(tl.id, cp1.id, { reason: 'test' }); + + expect(handler).toHaveBeenCalledWith({ + sourceTimelineId: tl.id, + forkTimelineId: fork.id, + checkpointId: cp1.id, + metadata: { reason: 'test' }, + }); + }); + + it('should increment forksCreated and timelinesCreated stats', async () => { + await engine.fork(tl.id, cp1.id); + + const stats = engine.getStats(); + expect(stats.forksCreated).toBe(1); + // 1 original + 1 fork + expect(stats.timelinesCreated).toBe(2); + }); + + it('should allow forking at the last checkpoint', async () => { + const fork = await engine.fork(tl.id, cp2.id); + expect(fork.checkpoints).toHaveLength(2); + }); + + it('should throw if timeline does not exist', async () => { + await expect(engine.fork('nonexistent', cp1.id)).rejects.toThrow('Timeline not found'); + }); + + it('should throw if checkpoint does not exist', async () => { + await expect(engine.fork(tl.id, 'cp_nonexistent')).rejects.toThrow('Checkpoint not found'); + }); + + it('should allow adding checkpoints to the forked timeline', async () => { + const fork = await engine.fork(tl.id, cp1.id); + const newCp = await engine.checkpoint(fork.id, { step: 'fork-2' }, 'forked step'); + + expect(newCp.timelineId).toBe(fork.id); + const storedFork = engine.timelines.get(fork.id); + expect(storedFork.checkpoints).toHaveLength(2); + }); + }); + + // --------------------------------------------------------------------------- + // rewind + // --------------------------------------------------------------------------- + + describe('rewind', () => { + let tl; + let cp1; + let cp2; + let cp3; + + beforeEach(async () => { + tl = await engine.createTimeline('session-1'); + cp1 = await engine.checkpoint(tl.id, { v: 1 }, 'v1'); + cp2 = await engine.checkpoint(tl.id, { v: 2 }, 'v2'); + cp3 = await engine.checkpoint(tl.id, { v: 3 }, 'v3'); + }); + + it('should mark subsequent checkpoints as rewound', async () => { + const result = await engine.rewind(tl.id, cp1.id); + + expect(result.rewoundCheckpoints).toHaveLength(2); + expect(result.rewoundCheckpoints).toContain(cp2.id); + expect(result.rewoundCheckpoints).toContain(cp3.id); + + const storedTl = engine.timelines.get(tl.id); + expect(storedTl.checkpoints[0].status).toBe(CheckpointStatus.ACTIVE); + expect(storedTl.checkpoints[1].status).toBe(CheckpointStatus.REWOUND); + expect(storedTl.checkpoints[2].status).toBe(CheckpointStatus.REWOUND); + }); + + it('should return the state at the rewind target', async () => { + const result = await engine.rewind(tl.id, cp1.id); + expect(result.state).toEqual({ v: 1 }); + }); + + it('should not mark the target checkpoint as rewound', async () => { + await engine.rewind(tl.id, cp2.id); + + const storedTl = engine.timelines.get(tl.id); + expect(storedTl.checkpoints[1].status).toBe(CheckpointStatus.ACTIVE); + expect(storedTl.checkpoints[2].status).toBe(CheckpointStatus.REWOUND); + }); + + it('should be a no-op when rewinding to the last checkpoint', async () => { + const result = await engine.rewind(tl.id, cp3.id); + + expect(result.rewoundCheckpoints).toHaveLength(0); + }); + + it('should emit timeline:rewound event', async () => { + const handler = jest.fn(); + engine.on('timeline:rewound', handler); + + await engine.rewind(tl.id, cp1.id); + + expect(handler).toHaveBeenCalledWith({ + timelineId: tl.id, + checkpointId: cp1.id, + rewoundCheckpoints: expect.arrayContaining([cp2.id, cp3.id]), + }); + }); + + it('should increment rewindsPerformed stat', async () => { + await engine.rewind(tl.id, cp1.id); + expect(engine.getStats().rewindsPerformed).toBe(1); + }); + + it('should skip already-rewound checkpoints', async () => { + await engine.rewind(tl.id, cp2.id); // rewinds cp3 + const result = await engine.rewind(tl.id, cp1.id); // should only rewind cp2 + + expect(result.rewoundCheckpoints).toHaveLength(1); + expect(result.rewoundCheckpoints).toContain(cp2.id); + }); + + it('should throw if timeline does not exist', async () => { + await expect(engine.rewind('nonexistent', cp1.id)).rejects.toThrow('Timeline not found'); + }); + + it('should throw if checkpoint does not exist', async () => { + await expect(engine.rewind(tl.id, 'cp_nonexistent')).rejects.toThrow( + 'Checkpoint not found' + ); + }); + }); + + // --------------------------------------------------------------------------- + // getReplayPlan + // --------------------------------------------------------------------------- + + describe('getReplayPlan', () => { + let tl; + let cp1; + let cp2; + let cp3; + + beforeEach(async () => { + tl = await engine.createTimeline('session-1'); + cp1 = await engine.checkpoint(tl.id, { v: 1 }, 'step-1'); + cp2 = await engine.checkpoint(tl.id, { v: 2 }, 'step-2'); + cp3 = await engine.checkpoint(tl.id, { v: 3 }, 'step-3'); + }); + + it('should return steps between two checkpoints', () => { + const plan = engine.getReplayPlan(tl.id, cp1.id, cp3.id); + + expect(plan.totalSteps).toBe(2); + expect(plan.steps).toHaveLength(2); + expect(plan.steps[0].checkpointId).toBe(cp2.id); + expect(plan.steps[1].checkpointId).toBe(cp3.id); + }); + + it('should return correct from/to metadata', () => { + const plan = engine.getReplayPlan(tl.id, cp1.id, cp3.id); + + expect(plan.from.checkpointId).toBe(cp1.id); + expect(plan.to.checkpointId).toBe(cp3.id); + expect(plan.timelineId).toBe(tl.id); + }); + + it('should return single step for adjacent checkpoints', () => { + const plan = engine.getReplayPlan(tl.id, cp1.id, cp2.id); + + expect(plan.totalSteps).toBe(1); + expect(plan.steps[0].checkpointId).toBe(cp2.id); + }); + + it('should throw if from comes after to', () => { + expect(() => engine.getReplayPlan(tl.id, cp3.id, cp1.id)).toThrow( + 'must precede toCheckpoint' + ); + }); + + it('should throw if from and to are the same', () => { + expect(() => engine.getReplayPlan(tl.id, cp1.id, cp1.id)).toThrow( + 'must precede toCheckpoint' + ); + }); + + it('should include rewound checkpoints in the plan', async () => { + await engine.rewind(tl.id, cp1.id); + + const plan = engine.getReplayPlan(tl.id, cp1.id, cp3.id); + expect(plan.steps[0].status).toBe(CheckpointStatus.REWOUND); + }); + + it('should throw if timeline does not exist', () => { + expect(() => engine.getReplayPlan('nonexistent', 'a', 'b')).toThrow('Timeline not found'); + }); + }); + + // --------------------------------------------------------------------------- + // compareTimelines + // --------------------------------------------------------------------------- + + describe('compareTimelines', () => { + it('should identify shared and divergent checkpoints', async () => { + const tl = await engine.createTimeline('s1'); + const cp1 = await engine.checkpoint(tl.id, { v: 1 }, 'shared'); + await engine.checkpoint(tl.id, { v: 2 }, 'original-only'); + + const fork = await engine.fork(tl.id, cp1.id); + await engine.checkpoint(fork.id, { v: 'fork-2' }, 'fork-only'); + + const comparison = engine.compareTimelines(tl.id, fork.id); + + expect(comparison.sharedCheckpoints.length).toBeGreaterThan(0); + expect(comparison.divergentCheckpoints.onlyInTimeline1.length).toBeGreaterThan(0); + expect(comparison.divergentCheckpoints.onlyInTimeline2.length).toBeGreaterThan(0); + }); + + it('should return correct timeline metadata', async () => { + const tl1 = await engine.createTimeline('s1'); + const tl2 = await engine.createTimeline('s2'); + await engine.checkpoint(tl1.id, { v: 1 }); + await engine.checkpoint(tl2.id, { v: 1 }); + + const comparison = engine.compareTimelines(tl1.id, tl2.id); + + expect(comparison.timeline1.id).toBe(tl1.id); + expect(comparison.timeline2.id).toBe(tl2.id); + expect(comparison.timeline1.totalCheckpoints).toBe(1); + expect(comparison.timeline2.totalCheckpoints).toBe(1); + // Unrelated timelines have no shared checkpoints (lineage-based) + expect(comparison.sharedCheckpoints).toHaveLength(0); + }); + + it('should handle timelines with no shared checkpoints', async () => { + const tl1 = await engine.createTimeline('s1'); + const tl2 = await engine.createTimeline('s2'); + await engine.checkpoint(tl1.id, { x: 'a' }); + await engine.checkpoint(tl2.id, { x: 'b' }); + + const comparison = engine.compareTimelines(tl1.id, tl2.id); + + expect(comparison.sharedCheckpoints).toHaveLength(0); + expect(comparison.commonAncestorIndex).toBe(-1); + }); + + it('should throw if either timeline does not exist', async () => { + const tl = await engine.createTimeline('s1'); + + expect(() => engine.compareTimelines(tl.id, 'nonexistent')).toThrow('Timeline not found'); + expect(() => engine.compareTimelines('nonexistent', tl.id)).toThrow('Timeline not found'); + }); + }); + + // --------------------------------------------------------------------------- + // getTimelineTree + // --------------------------------------------------------------------------- + + describe('getTimelineTree', () => { + it('should build a tree with no children for a simple timeline', async () => { + const tl = await engine.createTimeline('s1'); + await engine.checkpoint(tl.id, { v: 1 }); + + const tree = engine.getTimelineTree(tl.id); + + expect(tree.id).toBe(tl.id); + expect(tree.children).toHaveLength(0); + expect(tree.checkpointCount).toBe(1); + expect(tree.forkCount).toBe(0); + }); + + it('should include forked timelines as children', async () => { + const tl = await engine.createTimeline('s1'); + const cp = await engine.checkpoint(tl.id, { v: 1 }); + const fork = await engine.fork(tl.id, cp.id, { name: 'fork-1' }); + + const tree = engine.getTimelineTree(tl.id); + + expect(tree.children).toHaveLength(1); + expect(tree.children[0].id).toBe(fork.id); + expect(tree.children[0].parentId).toBe(tl.id); + }); + + it('should build nested trees for multi-level forks', async () => { + const tl = await engine.createTimeline('s1'); + const cp1 = await engine.checkpoint(tl.id, { v: 1 }); + + const fork1 = await engine.fork(tl.id, cp1.id); + const cpFork = await engine.checkpoint(fork1.id, { v: 'fork-2' }); + const fork2 = await engine.fork(fork1.id, cpFork.id); + + const tree = engine.getTimelineTree(tl.id); + + expect(tree.children).toHaveLength(1); + expect(tree.children[0].id).toBe(fork1.id); + expect(tree.children[0].children).toHaveLength(1); + expect(tree.children[0].children[0].id).toBe(fork2.id); + }); + + it('should include activeCheckpointCount in tree nodes', async () => { + const tl = await engine.createTimeline('s1'); + const cp1 = await engine.checkpoint(tl.id, { v: 1 }); + await engine.checkpoint(tl.id, { v: 2 }); + await engine.rewind(tl.id, cp1.id); + + const tree = engine.getTimelineTree(tl.id); + + expect(tree.checkpointCount).toBe(2); + expect(tree.activeCheckpointCount).toBe(1); + }); + + it('should throw if timeline does not exist', () => { + expect(() => engine.getTimelineTree('nonexistent')).toThrow('Timeline not found'); + }); + }); + + // --------------------------------------------------------------------------- + // listTimelines + // --------------------------------------------------------------------------- + + describe('listTimelines', () => { + it('should return all timelines when no filter is given', async () => { + await engine.createTimeline('s1'); + await engine.createTimeline('s2'); + + const list = await engine.listTimelines(); + + expect(list.length).toBe(2); + }); + + it('should filter by sessionId', async () => { + await engine.createTimeline('s1'); + await engine.createTimeline('s2'); + await engine.createTimeline('s1'); + + const list = await engine.listTimelines({ sessionId: 's1' }); + + expect(list.length).toBe(2); + list.forEach((tl) => { expect(tl.sessionId).toBe('s1'); }); + }); + + it('should filter by status', async () => { + const tl1 = await engine.createTimeline('s1'); + await engine.createTimeline('s2'); + + // Manually archive one + engine.timelines.get(tl1.id).status = TimelineStatus.ARCHIVED; + + const active = await engine.listTimelines({ status: TimelineStatus.ACTIVE }); + const archived = await engine.listTimelines({ status: TimelineStatus.ARCHIVED }); + + expect(active.length).toBe(1); + expect(archived.length).toBe(1); + }); + + it('should filter by parentId', async () => { + const tl = await engine.createTimeline('s1'); + const cp = await engine.checkpoint(tl.id, { v: 1 }); + await engine.fork(tl.id, cp.id); + await engine.fork(tl.id, cp.id); + + const forks = await engine.listTimelines({ parentId: tl.id }); + + expect(forks.length).toBe(2); + }); + + it('should return deep clones', async () => { + await engine.createTimeline('s1', { mutable: true }); + + const list = await engine.listTimelines(); + list[0].metadata.mutable = false; + + const listAgain = await engine.listTimelines(); + expect(listAgain[0].metadata.mutable).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // deleteTimeline + // --------------------------------------------------------------------------- + + describe('deleteTimeline', () => { + it('should remove a timeline from memory and disk', async () => { + const tl = await engine.createTimeline('s1'); + + const result = await engine.deleteTimeline(tl.id); + + expect(result.deleted).toContain(tl.id); + expect(result.count).toBe(1); + expect(engine.timelines.has(tl.id)).toBe(false); + expect(fs.unlink).toHaveBeenCalled(); + }); + + it('should recursively delete forks when deleteForks is true', async () => { + const tl = await engine.createTimeline('s1'); + const cp = await engine.checkpoint(tl.id, { v: 1 }); + const fork = await engine.fork(tl.id, cp.id); + + const result = await engine.deleteTimeline(tl.id, { deleteForks: true }); + + expect(result.deleted).toContain(tl.id); + expect(result.deleted).toContain(fork.id); + expect(result.count).toBe(2); + }); + + it('should not delete forks when deleteForks is false', async () => { + const tl = await engine.createTimeline('s1'); + const cp = await engine.checkpoint(tl.id, { v: 1 }); + const fork = await engine.fork(tl.id, cp.id); + + await engine.deleteTimeline(tl.id); + + expect(engine.timelines.has(fork.id)).toBe(true); + }); + + it('should remove fork reference from parent', async () => { + const parent = await engine.createTimeline('s1'); + const cp = await engine.checkpoint(parent.id, { v: 1 }); + const fork = await engine.fork(parent.id, cp.id); + + await engine.deleteTimeline(fork.id); + + const parentTimeline = engine.timelines.get(parent.id); + expect(parentTimeline.forks).toHaveLength(0); + }); + + it('should throw if timeline does not exist', async () => { + await expect(engine.deleteTimeline('nonexistent')).rejects.toThrow('Timeline not found'); + }); + }); + + // --------------------------------------------------------------------------- + // getStats + // --------------------------------------------------------------------------- + + describe('getStats', () => { + it('should return comprehensive statistics', async () => { + const tl = await engine.createTimeline('s1'); + const cp1 = await engine.checkpoint(tl.id, { v: 1 }); + await engine.checkpoint(tl.id, { v: 2 }); + await engine.fork(tl.id, cp1.id); + await engine.rewind(tl.id, cp1.id); + await engine.restoreCheckpoint(tl.id, cp1.id); + + const stats = engine.getStats(); + + expect(stats.timelinesCreated).toBe(2); // 1 original + 1 fork + expect(stats.checkpointsCreated).toBe(2); + expect(stats.forksCreated).toBe(1); + expect(stats.rewindsPerformed).toBe(1); + expect(stats.restoresPerformed).toBe(1); + expect(stats.totalTimelines).toBe(2); + expect(stats.activeTimelines).toBe(2); + expect(stats.totalCheckpoints).toBeGreaterThan(0); + }); + + it('should count archived timelines separately', async () => { + const tl = await engine.createTimeline('s1'); + await engine.createTimeline('s2'); + + engine.timelines.get(tl.id).status = TimelineStatus.ARCHIVED; + + const stats = engine.getStats(); + expect(stats.activeTimelines).toBe(1); + expect(stats.totalTimelines).toBe(2); + }); + }); + + // --------------------------------------------------------------------------- + // Persistence + // --------------------------------------------------------------------------- + + describe('persistence', () => { + it('should create storage directory on persist', async () => { + await engine.createTimeline('s1'); + + expect(fs.mkdir).toHaveBeenCalledWith('/tmp/test-timelines', { recursive: true }); + }); + + it('should write timeline as JSON', async () => { + const tl = await engine.createTimeline('s1'); + + const call = fs.writeFile.mock.calls.find((c) => c[0].includes(tl.id)); + expect(call).toBeDefined(); + + const written = JSON.parse(call[1]); + expect(written.id).toBe(tl.id); + expect(written.sessionId).toBe('s1'); + }); + + it('should skip persistence when autoPersist is false', async () => { + const noPersist = new TimeTravelEngine({ autoPersist: false }); + fs.writeFile.mockClear(); + + await noPersist.createTimeline('s1'); + + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('should emit error event on persistence failure when listener exists', async () => { + fs.mkdir.mockRejectedValueOnce(new Error('disk full')); + + const handler = jest.fn(); + engine.on('error', handler); + + await engine.createTimeline('s1'); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ operation: 'persist' }) + ); + }); + + it('should not throw on persistence failure when no error listener exists', async () => { + fs.mkdir.mockRejectedValueOnce(new Error('disk full')); + + // No error listener registered — should not throw + await expect(engine.createTimeline('s1')).resolves.toBeDefined(); + }); + + it('should load all timelines from disk', async () => { + // Create and persist a timeline + const tl = await engine.createTimeline('s1'); + await engine.checkpoint(tl.id, { data: 'persisted' }, 'label'); + + // Create a new engine instance (simulating restart) + const engine2 = new TimeTravelEngine({ + storageDir: '/tmp/test-timelines', + autoPersist: false, + }); + + // Load from disk + const list = await engine2.listTimelines(); + + expect(list.length).toBeGreaterThan(0); + expect(list.find((t) => t.id === tl.id)).toBeDefined(); + }); + + it('should handle corrupt JSON files gracefully', async () => { + // Write a corrupt file + fs._store.set('/tmp/test-timelines/corrupt.json', 'not valid json {{{'); + + const engine2 = new TimeTravelEngine({ + storageDir: '/tmp/test-timelines', + autoPersist: false, + }); + + // Should not throw + const list = await engine2.listTimelines(); + expect(Array.isArray(list)).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // Edge cases & integration + // --------------------------------------------------------------------------- + + describe('edge cases', () => { + it('should handle checkpoint with null state', async () => { + const tl = await engine.createTimeline('s1'); + const cp = await engine.checkpoint(tl.id, null, 'null state'); + + expect(cp.state).toBeNull(); + const restored = await engine.restoreCheckpoint(tl.id, cp.id); + expect(restored.state).toBeNull(); + }); + + it('should handle checkpoint with undefined state', async () => { + const tl = await engine.createTimeline('s1'); + const cp = await engine.checkpoint(tl.id, undefined, 'undefined state'); + + const restored = await engine.restoreCheckpoint(tl.id, cp.id); + expect(restored.state).toBeUndefined(); + }); + + it('should handle complex nested state objects', async () => { + const complexState = { + agents: [{ id: 1, config: { nested: { deep: true } } }], + execution: { steps: [1, 2, 3], results: { a: { b: { c: 'd' } } } }, + }; + + const tl = await engine.createTimeline('s1'); + const cp = await engine.checkpoint(tl.id, complexState, 'complex'); + + const restored = await engine.restoreCheckpoint(tl.id, cp.id); + expect(restored.state).toEqual(complexState); + }); + + it('should allow forking and continuing independently', async () => { + const tl = await engine.createTimeline('s1'); + const cp1 = await engine.checkpoint(tl.id, { step: 1 }); + + const fork = await engine.fork(tl.id, cp1.id); + + // Both can add checkpoints independently + await engine.checkpoint(tl.id, { step: 2, branch: 'main' }); + await engine.checkpoint(fork.id, { step: 2, branch: 'fork' }); + + const mainTl = engine.timelines.get(tl.id); + const forkTl = engine.timelines.get(fork.id); + + expect(mainTl.checkpoints).toHaveLength(2); + expect(forkTl.checkpoints).toHaveLength(2); + expect(mainTl.checkpoints[1].state.branch).toBe('main'); + expect(forkTl.checkpoints[1].state.branch).toBe('fork'); + }); + + it('should handle rewind then new checkpoints correctly', async () => { + const tl = await engine.createTimeline('s1'); + const cp1 = await engine.checkpoint(tl.id, { v: 1 }); + await engine.checkpoint(tl.id, { v: 2 }); + + await engine.rewind(tl.id, cp1.id); + + // Add new checkpoint after rewind + const cpNew = await engine.checkpoint(tl.id, { v: 'new' }, 'after-rewind'); + + // Index is based on total checkpoints length (position in array) + expect(cpNew.index).toBe(2); + expect(cpNew.status).toBe(CheckpointStatus.ACTIVE); + }); + + it('should handle multiple forks from the same checkpoint', async () => { + const tl = await engine.createTimeline('s1'); + const cp = await engine.checkpoint(tl.id, { v: 1 }); + + const fork1 = await engine.fork(tl.id, cp.id, { name: 'fork-1' }); + const fork2 = await engine.fork(tl.id, cp.id, { name: 'fork-2' }); + + const source = engine.timelines.get(tl.id); + expect(source.forks).toHaveLength(2); + expect(fork1.id).not.toBe(fork2.id); + }); + }); +});