musubi-sdd 6.1.2 → 6.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,584 @@
1
+ /**
2
+ * Rollback Manager
3
+ *
4
+ * Supports rollback to previous state with cleanup.
5
+ *
6
+ * Requirement: IMP-6.2-008-02
7
+ *
8
+ * @module enterprise/rollback-manager
9
+ */
10
+
11
+ const fs = require('fs').promises;
12
+ const path = require('path');
13
+ const { execSync } = require('child_process');
14
+
15
+ /**
16
+ * Rollback granularity levels
17
+ */
18
+ const ROLLBACK_LEVEL = {
19
+ FILE: 'file',
20
+ COMMIT: 'commit',
21
+ STAGE: 'stage',
22
+ SPRINT: 'sprint'
23
+ };
24
+
25
+ /**
26
+ * Rollback status
27
+ */
28
+ const ROLLBACK_STATUS = {
29
+ PENDING: 'pending',
30
+ IN_PROGRESS: 'in-progress',
31
+ COMPLETED: 'completed',
32
+ FAILED: 'failed',
33
+ CANCELLED: 'cancelled'
34
+ };
35
+
36
+ /**
37
+ * Workflow stages
38
+ */
39
+ const WORKFLOW_STAGE = {
40
+ REQUIREMENTS: 'requirements',
41
+ DESIGN: 'design',
42
+ TASKS: 'tasks',
43
+ IMPLEMENT: 'implement',
44
+ VALIDATE: 'validate'
45
+ };
46
+
47
+ /**
48
+ * Rollback Manager
49
+ */
50
+ class RollbackManager {
51
+ /**
52
+ * Create a new RollbackManager
53
+ * @param {Object} config - Configuration options
54
+ */
55
+ constructor(config = {}) {
56
+ this.config = {
57
+ storageDir: config.storageDir || 'storage/rollbacks',
58
+ backupDir: config.backupDir || 'storage/backups',
59
+ maxHistory: config.maxHistory || 50,
60
+ requireConfirmation: config.requireConfirmation !== false,
61
+ gitEnabled: config.gitEnabled !== false,
62
+ ...config
63
+ };
64
+
65
+ this.rollbackHistory = [];
66
+ this.checkpoints = new Map();
67
+ }
68
+
69
+ /**
70
+ * Create a checkpoint for rollback
71
+ * @param {string} name - Checkpoint name
72
+ * @param {Object} options - Checkpoint options
73
+ * @returns {Promise<Object>} Checkpoint info
74
+ */
75
+ async createCheckpoint(name, options = {}) {
76
+ const checkpoint = {
77
+ id: this.generateId('ckpt'),
78
+ name,
79
+ timestamp: new Date().toISOString(),
80
+ level: options.level || ROLLBACK_LEVEL.COMMIT,
81
+ stage: options.stage || WORKFLOW_STAGE.IMPLEMENT,
82
+ description: options.description || '',
83
+ files: [],
84
+ gitRef: null,
85
+ metadata: options.metadata || {}
86
+ };
87
+
88
+ // Capture current state
89
+ if (options.files && options.files.length > 0) {
90
+ checkpoint.files = await this.captureFiles(options.files);
91
+ }
92
+
93
+ // Capture git ref if enabled
94
+ if (this.config.gitEnabled) {
95
+ checkpoint.gitRef = this.getCurrentGitRef();
96
+ }
97
+
98
+ // Store checkpoint
99
+ this.checkpoints.set(checkpoint.id, checkpoint);
100
+ await this.saveCheckpoint(checkpoint);
101
+
102
+ return checkpoint;
103
+ }
104
+
105
+ /**
106
+ * Capture file contents for backup
107
+ * @param {Array<string>} files - File paths
108
+ * @returns {Promise<Array>} File backups
109
+ */
110
+ async captureFiles(files) {
111
+ const backups = [];
112
+
113
+ for (const filePath of files) {
114
+ try {
115
+ const content = await fs.readFile(filePath, 'utf-8');
116
+ const stats = await fs.stat(filePath);
117
+
118
+ backups.push({
119
+ path: filePath,
120
+ content,
121
+ mode: stats.mode,
122
+ mtime: stats.mtime.toISOString(),
123
+ size: stats.size
124
+ });
125
+ } catch (error) {
126
+ // File doesn't exist or can't be read
127
+ backups.push({
128
+ path: filePath,
129
+ exists: false,
130
+ error: error.message
131
+ });
132
+ }
133
+ }
134
+
135
+ return backups;
136
+ }
137
+
138
+ /**
139
+ * Get current git reference
140
+ * @returns {string|null} Git ref
141
+ */
142
+ getCurrentGitRef() {
143
+ try {
144
+ return execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Rollback to checkpoint
152
+ * @param {string} checkpointId - Checkpoint ID
153
+ * @param {Object} options - Rollback options
154
+ * @returns {Promise<Object>} Rollback result
155
+ */
156
+ async rollback(checkpointId, options = {}) {
157
+ const checkpoint = this.checkpoints.get(checkpointId) || await this.loadCheckpoint(checkpointId);
158
+
159
+ if (!checkpoint) {
160
+ throw new Error(`Checkpoint not found: ${checkpointId}`);
161
+ }
162
+
163
+ // Create rollback record
164
+ const rollback = {
165
+ id: this.generateId('rb'),
166
+ checkpointId,
167
+ timestamp: new Date().toISOString(),
168
+ status: ROLLBACK_STATUS.PENDING,
169
+ level: checkpoint.level,
170
+ beforeState: null,
171
+ changes: [],
172
+ options
173
+ };
174
+
175
+ // Require confirmation if enabled
176
+ if (this.config.requireConfirmation && !options.confirmed) {
177
+ return {
178
+ ...rollback,
179
+ status: ROLLBACK_STATUS.PENDING,
180
+ requiresConfirmation: true,
181
+ checkpoint: this.summarizeCheckpoint(checkpoint)
182
+ };
183
+ }
184
+
185
+ try {
186
+ rollback.status = ROLLBACK_STATUS.IN_PROGRESS;
187
+
188
+ // Capture current state before rollback
189
+ if (checkpoint.files.length > 0) {
190
+ rollback.beforeState = await this.captureFiles(
191
+ checkpoint.files.map(f => f.path)
192
+ );
193
+ }
194
+
195
+ // Perform rollback based on level
196
+ switch (checkpoint.level) {
197
+ case ROLLBACK_LEVEL.FILE:
198
+ rollback.changes = await this.rollbackFiles(checkpoint);
199
+ break;
200
+ case ROLLBACK_LEVEL.COMMIT:
201
+ rollback.changes = await this.rollbackToCommit(checkpoint);
202
+ break;
203
+ case ROLLBACK_LEVEL.STAGE:
204
+ rollback.changes = await this.rollbackToStage(checkpoint);
205
+ break;
206
+ case ROLLBACK_LEVEL.SPRINT:
207
+ rollback.changes = await this.rollbackToSprint(checkpoint);
208
+ break;
209
+ default:
210
+ throw new Error(`Unknown rollback level: ${checkpoint.level}`);
211
+ }
212
+
213
+ rollback.status = ROLLBACK_STATUS.COMPLETED;
214
+ } catch (error) {
215
+ rollback.status = ROLLBACK_STATUS.FAILED;
216
+ rollback.error = error.message;
217
+ }
218
+
219
+ // Record rollback
220
+ this.rollbackHistory.unshift(rollback);
221
+ await this.saveRollback(rollback);
222
+
223
+ return rollback;
224
+ }
225
+
226
+ /**
227
+ * Rollback files to checkpoint state
228
+ * @param {Object} checkpoint - Checkpoint data
229
+ * @returns {Promise<Array>} Changes made
230
+ */
231
+ async rollbackFiles(checkpoint) {
232
+ const changes = [];
233
+
234
+ for (const file of checkpoint.files) {
235
+ try {
236
+ if (file.exists === false) {
237
+ // File didn't exist at checkpoint - delete if exists now
238
+ try {
239
+ await fs.unlink(file.path);
240
+ changes.push({ path: file.path, action: 'deleted' });
241
+ } catch {
242
+ // File doesn't exist, nothing to delete
243
+ }
244
+ } else {
245
+ // Restore file content
246
+ await fs.mkdir(path.dirname(file.path), { recursive: true });
247
+ await fs.writeFile(file.path, file.content, 'utf-8');
248
+ changes.push({ path: file.path, action: 'restored' });
249
+ }
250
+ } catch (error) {
251
+ changes.push({ path: file.path, action: 'failed', error: error.message });
252
+ }
253
+ }
254
+
255
+ return changes;
256
+ }
257
+
258
+ /**
259
+ * Rollback to git commit
260
+ * @param {Object} checkpoint - Checkpoint data
261
+ * @returns {Promise<Array>} Changes made
262
+ */
263
+ async rollbackToCommit(checkpoint) {
264
+ const changes = [];
265
+
266
+ if (!this.config.gitEnabled || !checkpoint.gitRef) {
267
+ throw new Error('Git rollback not available');
268
+ }
269
+
270
+ try {
271
+ // Create backup branch
272
+ const backupBranch = `backup-${Date.now()}`;
273
+ execSync(`git branch ${backupBranch}`, { encoding: 'utf-8' });
274
+ changes.push({ action: 'branch-created', branch: backupBranch });
275
+
276
+ // Reset to checkpoint
277
+ execSync(`git reset --hard ${checkpoint.gitRef}`, { encoding: 'utf-8' });
278
+ changes.push({ action: 'reset', ref: checkpoint.gitRef });
279
+ } catch (error) {
280
+ changes.push({ action: 'failed', error: error.message });
281
+ }
282
+
283
+ return changes;
284
+ }
285
+
286
+ /**
287
+ * Rollback to workflow stage
288
+ * @param {Object} checkpoint - Checkpoint data
289
+ * @returns {Promise<Array>} Changes made
290
+ */
291
+ async rollbackToStage(checkpoint) {
292
+ const changes = [];
293
+
294
+ // Rollback files first
295
+ if (checkpoint.files.length > 0) {
296
+ const fileChanges = await this.rollbackFiles(checkpoint);
297
+ changes.push(...fileChanges);
298
+ }
299
+
300
+ // Update workflow state
301
+ changes.push({ action: 'stage-reset', stage: checkpoint.stage });
302
+
303
+ return changes;
304
+ }
305
+
306
+ /**
307
+ * Rollback to sprint start
308
+ * @param {Object} checkpoint - Checkpoint data
309
+ * @returns {Promise<Array>} Changes made
310
+ */
311
+ async rollbackToSprint(checkpoint) {
312
+ const changes = [];
313
+
314
+ // Full rollback including git and files
315
+ if (checkpoint.gitRef && this.config.gitEnabled) {
316
+ const commitChanges = await this.rollbackToCommit(checkpoint);
317
+ changes.push(...commitChanges);
318
+ }
319
+
320
+ if (checkpoint.files.length > 0) {
321
+ const fileChanges = await this.rollbackFiles(checkpoint);
322
+ changes.push(...fileChanges);
323
+ }
324
+
325
+ changes.push({ action: 'sprint-reset', metadata: checkpoint.metadata });
326
+
327
+ return changes;
328
+ }
329
+
330
+ /**
331
+ * Cancel pending rollback
332
+ * @param {string} rollbackId - Rollback ID
333
+ * @returns {Object} Updated rollback
334
+ */
335
+ cancelRollback(rollbackId) {
336
+ const rollback = this.rollbackHistory.find(r => r.id === rollbackId);
337
+
338
+ if (!rollback) {
339
+ throw new Error(`Rollback not found: ${rollbackId}`);
340
+ }
341
+
342
+ if (rollback.status !== ROLLBACK_STATUS.PENDING) {
343
+ throw new Error(`Cannot cancel rollback in status: ${rollback.status}`);
344
+ }
345
+
346
+ rollback.status = ROLLBACK_STATUS.CANCELLED;
347
+ rollback.cancelledAt = new Date().toISOString();
348
+
349
+ return rollback;
350
+ }
351
+
352
+ /**
353
+ * Get rollback history
354
+ * @param {Object} filter - Filter options
355
+ * @returns {Array} Filtered history
356
+ */
357
+ getHistory(filter = {}) {
358
+ let history = [...this.rollbackHistory];
359
+
360
+ if (filter.status) {
361
+ history = history.filter(r => r.status === filter.status);
362
+ }
363
+ if (filter.level) {
364
+ history = history.filter(r => r.level === filter.level);
365
+ }
366
+ if (filter.limit) {
367
+ history = history.slice(0, filter.limit);
368
+ }
369
+
370
+ return history;
371
+ }
372
+
373
+ /**
374
+ * List checkpoints
375
+ * @param {Object} filter - Filter options
376
+ * @returns {Array} Checkpoints
377
+ */
378
+ listCheckpoints(filter = {}) {
379
+ let checkpoints = Array.from(this.checkpoints.values());
380
+
381
+ if (filter.level) {
382
+ checkpoints = checkpoints.filter(c => c.level === filter.level);
383
+ }
384
+ if (filter.stage) {
385
+ checkpoints = checkpoints.filter(c => c.stage === filter.stage);
386
+ }
387
+
388
+ return checkpoints.map(c => this.summarizeCheckpoint(c));
389
+ }
390
+
391
+ /**
392
+ * Summarize checkpoint for display
393
+ * @param {Object} checkpoint - Checkpoint data
394
+ * @returns {Object} Summary
395
+ */
396
+ summarizeCheckpoint(checkpoint) {
397
+ return {
398
+ id: checkpoint.id,
399
+ name: checkpoint.name,
400
+ timestamp: checkpoint.timestamp,
401
+ level: checkpoint.level,
402
+ stage: checkpoint.stage,
403
+ description: checkpoint.description,
404
+ fileCount: checkpoint.files.length,
405
+ hasGitRef: !!checkpoint.gitRef
406
+ };
407
+ }
408
+
409
+ /**
410
+ * Save checkpoint to storage
411
+ * @param {Object} checkpoint - Checkpoint data
412
+ * @returns {Promise<string>} File path
413
+ */
414
+ async saveCheckpoint(checkpoint) {
415
+ await this.ensureStorageDir();
416
+
417
+ const fileName = `checkpoint-${checkpoint.id}.json`;
418
+ const filePath = path.join(this.config.storageDir, fileName);
419
+
420
+ await fs.writeFile(filePath, JSON.stringify(checkpoint, null, 2), 'utf-8');
421
+ return filePath;
422
+ }
423
+
424
+ /**
425
+ * Load checkpoint from storage
426
+ * @param {string} id - Checkpoint ID
427
+ * @returns {Promise<Object|null>} Checkpoint or null
428
+ */
429
+ async loadCheckpoint(id) {
430
+ const fileName = `checkpoint-${id}.json`;
431
+ const filePath = path.join(this.config.storageDir, fileName);
432
+
433
+ try {
434
+ const content = await fs.readFile(filePath, 'utf-8');
435
+ const checkpoint = JSON.parse(content);
436
+ this.checkpoints.set(id, checkpoint);
437
+ return checkpoint;
438
+ } catch {
439
+ return null;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Save rollback record
445
+ * @param {Object} rollback - Rollback data
446
+ * @returns {Promise<string>} File path
447
+ */
448
+ async saveRollback(rollback) {
449
+ await this.ensureStorageDir();
450
+
451
+ const fileName = `rollback-${rollback.id}.json`;
452
+ const filePath = path.join(this.config.storageDir, fileName);
453
+
454
+ await fs.writeFile(filePath, JSON.stringify(rollback, null, 2), 'utf-8');
455
+ return filePath;
456
+ }
457
+
458
+ /**
459
+ * Ensure storage directory exists
460
+ * @returns {Promise<void>}
461
+ */
462
+ async ensureStorageDir() {
463
+ await fs.mkdir(this.config.storageDir, { recursive: true });
464
+ }
465
+
466
+ /**
467
+ * Generate unique ID
468
+ * @param {string} prefix - ID prefix
469
+ * @returns {string} Unique ID
470
+ */
471
+ generateId(prefix = 'id') {
472
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
473
+ }
474
+
475
+ /**
476
+ * Generate rollback report
477
+ * @param {string} rollbackId - Rollback ID
478
+ * @returns {string} Markdown report
479
+ */
480
+ generateReport(rollbackId) {
481
+ const rollback = this.rollbackHistory.find(r => r.id === rollbackId);
482
+
483
+ if (!rollback) {
484
+ throw new Error(`Rollback not found: ${rollbackId}`);
485
+ }
486
+
487
+ const lines = [];
488
+
489
+ lines.push('# Rollback Report');
490
+ lines.push('');
491
+ lines.push(`**Rollback ID**: ${rollback.id}`);
492
+ lines.push(`**Checkpoint ID**: ${rollback.checkpointId}`);
493
+ lines.push(`**Timestamp**: ${rollback.timestamp}`);
494
+ lines.push(`**Status**: ${rollback.status}`);
495
+ lines.push(`**Level**: ${rollback.level}`);
496
+ lines.push('');
497
+
498
+ if (rollback.status === ROLLBACK_STATUS.COMPLETED) {
499
+ lines.push('## Changes Applied');
500
+ lines.push('');
501
+ lines.push('| Path/Action | Result |');
502
+ lines.push('|-------------|--------|');
503
+ for (const change of rollback.changes) {
504
+ if (change.path) {
505
+ lines.push(`| ${change.path} | ${change.action} |`);
506
+ } else {
507
+ lines.push(`| ${change.action} | ${change.ref || change.branch || 'OK'} |`);
508
+ }
509
+ }
510
+ lines.push('');
511
+ }
512
+
513
+ if (rollback.status === ROLLBACK_STATUS.FAILED) {
514
+ lines.push('## Error');
515
+ lines.push('');
516
+ lines.push(`\`\`\`\n${rollback.error}\n\`\`\``);
517
+ }
518
+
519
+ return lines.join('\n');
520
+ }
521
+
522
+ /**
523
+ * Delete checkpoint
524
+ * @param {string} checkpointId - Checkpoint ID
525
+ * @returns {Promise<boolean>} Success
526
+ */
527
+ async deleteCheckpoint(checkpointId) {
528
+ this.checkpoints.delete(checkpointId);
529
+
530
+ const fileName = `checkpoint-${checkpointId}.json`;
531
+ const filePath = path.join(this.config.storageDir, fileName);
532
+
533
+ try {
534
+ await fs.unlink(filePath);
535
+ return true;
536
+ } catch {
537
+ return false;
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Clean old checkpoints
543
+ * @param {number} maxAge - Max age in days
544
+ * @returns {Promise<number>} Number deleted
545
+ */
546
+ async cleanOldCheckpoints(maxAge = 30) {
547
+ const cutoff = new Date();
548
+ cutoff.setDate(cutoff.getDate() - maxAge);
549
+
550
+ let deleted = 0;
551
+ const toDelete = [];
552
+
553
+ for (const [id, checkpoint] of this.checkpoints) {
554
+ if (new Date(checkpoint.timestamp) < cutoff) {
555
+ toDelete.push(id);
556
+ }
557
+ }
558
+
559
+ for (const id of toDelete) {
560
+ if (await this.deleteCheckpoint(id)) {
561
+ deleted++;
562
+ }
563
+ }
564
+
565
+ return deleted;
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Create a new RollbackManager instance
571
+ * @param {Object} config - Configuration options
572
+ * @returns {RollbackManager}
573
+ */
574
+ function createRollbackManager(config = {}) {
575
+ return new RollbackManager(config);
576
+ }
577
+
578
+ module.exports = {
579
+ RollbackManager,
580
+ createRollbackManager,
581
+ ROLLBACK_LEVEL,
582
+ ROLLBACK_STATUS,
583
+ WORKFLOW_STAGE
584
+ };