@sylphx/flow 3.20.0 → 3.21.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.
- package/CHANGELOG.md +26 -0
- package/package.json +1 -1
- package/src/commands/flow/execute-v2.ts +19 -1
- package/src/core/__tests__/cleanup-handler.test.ts +152 -25
- package/src/core/__tests__/session-cleanup.test.ts +329 -35
- package/src/core/backup-manager.ts +99 -9
- package/src/core/cleanup-handler.ts +134 -64
- package/src/core/flow-executor.ts +35 -25
- package/src/core/project-manager.ts +16 -11
- package/src/core/session-manager.ts +223 -147
- package/src/targets/claude-code.ts +8 -23
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for SessionManager
|
|
3
|
-
* Covers:
|
|
2
|
+
* Tests for SessionManager — PID-based session management
|
|
3
|
+
* Covers: acquireSession, releaseSession, detectOrphanedSessions,
|
|
4
|
+
* finalizeSessionCleanup, cleanupSessionHistory
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import fs from 'node:fs';
|
|
7
8
|
import os from 'node:os';
|
|
8
9
|
import path from 'node:path';
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
11
|
import { ProjectManager } from '../project-manager.js';
|
|
11
12
|
import { SessionManager } from '../session-manager.js';
|
|
12
13
|
|
|
@@ -39,6 +40,331 @@ describe('SessionManager', () => {
|
|
|
39
40
|
}
|
|
40
41
|
});
|
|
41
42
|
|
|
43
|
+
// --- acquireSession ---
|
|
44
|
+
|
|
45
|
+
describe('acquireSession()', () => {
|
|
46
|
+
it('should detect first session via atomic mkdir', async () => {
|
|
47
|
+
const projectPath = path.join(tempDir, 'project');
|
|
48
|
+
fs.mkdirSync(projectPath, { recursive: true });
|
|
49
|
+
const hash = projectManager.getProjectHash(projectPath);
|
|
50
|
+
|
|
51
|
+
const result = await sessionManager.acquireSession(hash, projectPath, 'claude-code', {
|
|
52
|
+
sessionId: 'session-1',
|
|
53
|
+
backupPath: '/tmp/backup',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result.isFirstSession).toBe(true);
|
|
57
|
+
expect(result.backupRef).not.toBeNull();
|
|
58
|
+
expect(result.backupRef?.sessionId).toBe('session-1');
|
|
59
|
+
expect(result.backupRef?.createdByPid).toBe(process.pid);
|
|
60
|
+
|
|
61
|
+
// Verify PID file was written
|
|
62
|
+
const paths = projectManager.getProjectPaths(hash);
|
|
63
|
+
const pidFile = path.join(paths.pidsDir, `${process.pid}.json`);
|
|
64
|
+
expect(fs.existsSync(pidFile)).toBe(true);
|
|
65
|
+
|
|
66
|
+
// Verify backup.json was written
|
|
67
|
+
expect(fs.existsSync(paths.backupRefFile)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should detect join when pids/ already exists', async () => {
|
|
71
|
+
const projectPath = path.join(tempDir, 'project');
|
|
72
|
+
fs.mkdirSync(projectPath, { recursive: true });
|
|
73
|
+
const hash = projectManager.getProjectHash(projectPath);
|
|
74
|
+
const paths = projectManager.getProjectPaths(hash);
|
|
75
|
+
|
|
76
|
+
// Pre-create pids/ directory and backup.json (simulating first session)
|
|
77
|
+
fs.mkdirSync(paths.pidsDir, { recursive: true });
|
|
78
|
+
const backupRef = {
|
|
79
|
+
sessionId: 'session-existing',
|
|
80
|
+
backupPath: '/tmp/backup',
|
|
81
|
+
projectPath,
|
|
82
|
+
target: 'claude-code',
|
|
83
|
+
createdAt: new Date().toISOString(),
|
|
84
|
+
createdByPid: 99999,
|
|
85
|
+
};
|
|
86
|
+
fs.writeFileSync(paths.backupRefFile, JSON.stringify(backupRef));
|
|
87
|
+
|
|
88
|
+
// Second process joins
|
|
89
|
+
const result = await sessionManager.acquireSession(hash, projectPath, 'claude-code');
|
|
90
|
+
|
|
91
|
+
expect(result.isFirstSession).toBe(false);
|
|
92
|
+
expect(result.backupRef?.sessionId).toBe('session-existing');
|
|
93
|
+
|
|
94
|
+
// Verify our PID file was written
|
|
95
|
+
const pidFile = path.join(paths.pidsDir, `${process.pid}.json`);
|
|
96
|
+
expect(fs.existsSync(pidFile)).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should handle race condition (mkdir EEXIST)', async () => {
|
|
100
|
+
const projectPath = path.join(tempDir, 'project');
|
|
101
|
+
fs.mkdirSync(projectPath, { recursive: true });
|
|
102
|
+
const hash = projectManager.getProjectHash(projectPath);
|
|
103
|
+
const paths = projectManager.getProjectPaths(hash);
|
|
104
|
+
|
|
105
|
+
// Pre-create the session dir but NOT pids/
|
|
106
|
+
fs.mkdirSync(paths.sessionDir, { recursive: true });
|
|
107
|
+
|
|
108
|
+
// First call creates pids/
|
|
109
|
+
const result1 = await sessionManager.acquireSession(hash, projectPath, 'claude-code', {
|
|
110
|
+
sessionId: 'session-race',
|
|
111
|
+
backupPath: '/tmp/backup',
|
|
112
|
+
});
|
|
113
|
+
expect(result1.isFirstSession).toBe(true);
|
|
114
|
+
|
|
115
|
+
// Second call should get EEXIST and join
|
|
116
|
+
const result2 = await sessionManager.acquireSession(hash, projectPath, 'claude-code');
|
|
117
|
+
expect(result2.isFirstSession).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// --- releaseSession ---
|
|
122
|
+
|
|
123
|
+
describe('releaseSession()', () => {
|
|
124
|
+
it('should return shouldRestore=true when last PID', async () => {
|
|
125
|
+
const projectPath = path.join(tempDir, 'project');
|
|
126
|
+
fs.mkdirSync(projectPath, { recursive: true });
|
|
127
|
+
const hash = projectManager.getProjectHash(projectPath);
|
|
128
|
+
|
|
129
|
+
// Acquire session
|
|
130
|
+
await sessionManager.acquireSession(hash, projectPath, 'claude-code', {
|
|
131
|
+
sessionId: 'session-1',
|
|
132
|
+
backupPath: '/tmp/backup',
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Release — we're the only PID
|
|
136
|
+
const result = await sessionManager.releaseSession(hash);
|
|
137
|
+
|
|
138
|
+
expect(result.shouldRestore).toBe(true);
|
|
139
|
+
expect(result.backupRef?.sessionId).toBe('session-1');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should return shouldRestore=false when other alive PIDs exist', async () => {
|
|
143
|
+
const projectPath = path.join(tempDir, 'project');
|
|
144
|
+
fs.mkdirSync(projectPath, { recursive: true });
|
|
145
|
+
const hash = projectManager.getProjectHash(projectPath);
|
|
146
|
+
const paths = projectManager.getProjectPaths(hash);
|
|
147
|
+
|
|
148
|
+
// Acquire session
|
|
149
|
+
await sessionManager.acquireSession(hash, projectPath, 'claude-code', {
|
|
150
|
+
sessionId: 'session-1',
|
|
151
|
+
backupPath: '/tmp/backup',
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Simulate another alive process (PID 1 is always alive on Unix)
|
|
155
|
+
fs.writeFileSync(
|
|
156
|
+
path.join(paths.pidsDir, '1.json'),
|
|
157
|
+
JSON.stringify({ pid: 1, startTime: new Date().toISOString() })
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Release — PID 1 is still alive
|
|
161
|
+
const result = await sessionManager.releaseSession(hash);
|
|
162
|
+
|
|
163
|
+
expect(result.shouldRestore).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should clean dead PID files during release', async () => {
|
|
167
|
+
const projectPath = path.join(tempDir, 'project');
|
|
168
|
+
fs.mkdirSync(projectPath, { recursive: true });
|
|
169
|
+
const hash = projectManager.getProjectHash(projectPath);
|
|
170
|
+
const paths = projectManager.getProjectPaths(hash);
|
|
171
|
+
|
|
172
|
+
// Acquire session
|
|
173
|
+
await sessionManager.acquireSession(hash, projectPath, 'claude-code', {
|
|
174
|
+
sessionId: 'session-1',
|
|
175
|
+
backupPath: '/tmp/backup',
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Simulate a dead process (PID that doesn't exist)
|
|
179
|
+
fs.writeFileSync(
|
|
180
|
+
path.join(paths.pidsDir, '999999.json'),
|
|
181
|
+
JSON.stringify({ pid: 999999, startTime: new Date().toISOString() })
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Release — should clean dead PID file
|
|
185
|
+
const result = await sessionManager.releaseSession(hash);
|
|
186
|
+
|
|
187
|
+
// Dead PID file should be cleaned
|
|
188
|
+
expect(fs.existsSync(path.join(paths.pidsDir, '999999.json'))).toBe(false);
|
|
189
|
+
// Only our PID was alive, and we removed it — shouldRestore
|
|
190
|
+
expect(result.shouldRestore).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// --- detectOrphanedSessions ---
|
|
195
|
+
|
|
196
|
+
describe('detectOrphanedSessions()', () => {
|
|
197
|
+
it('should detect orphaned sessions (backup.json + no alive PIDs)', async () => {
|
|
198
|
+
const hash = 'orphanedproject1';
|
|
199
|
+
const paths = projectManager.getProjectPaths(hash);
|
|
200
|
+
|
|
201
|
+
// Create session dir with backup.json and dead PID
|
|
202
|
+
fs.mkdirSync(paths.pidsDir, { recursive: true });
|
|
203
|
+
const backupRef = {
|
|
204
|
+
sessionId: 'session-orphan',
|
|
205
|
+
backupPath: '/tmp/backup',
|
|
206
|
+
projectPath: '/tmp/project',
|
|
207
|
+
target: 'claude-code',
|
|
208
|
+
createdAt: new Date().toISOString(),
|
|
209
|
+
createdByPid: 999999,
|
|
210
|
+
};
|
|
211
|
+
fs.writeFileSync(paths.backupRefFile, JSON.stringify(backupRef));
|
|
212
|
+
fs.writeFileSync(
|
|
213
|
+
path.join(paths.pidsDir, '999999.json'),
|
|
214
|
+
JSON.stringify({ pid: 999999 })
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const orphaned = await sessionManager.detectOrphanedSessions();
|
|
218
|
+
|
|
219
|
+
expect(orphaned.size).toBe(1);
|
|
220
|
+
expect(orphaned.has(hash)).toBe(true);
|
|
221
|
+
expect(orphaned.get(hash)?.sessionId).toBe('session-orphan');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should NOT detect sessions with alive PIDs as orphaned', async () => {
|
|
225
|
+
const hash = 'activeproject1';
|
|
226
|
+
const paths = projectManager.getProjectPaths(hash);
|
|
227
|
+
|
|
228
|
+
// Create session dir with backup.json and alive PID (PID 1)
|
|
229
|
+
fs.mkdirSync(paths.pidsDir, { recursive: true });
|
|
230
|
+
fs.writeFileSync(
|
|
231
|
+
paths.backupRefFile,
|
|
232
|
+
JSON.stringify({
|
|
233
|
+
sessionId: 'session-active',
|
|
234
|
+
backupPath: '/tmp/backup',
|
|
235
|
+
projectPath: '/tmp/project',
|
|
236
|
+
target: 'claude-code',
|
|
237
|
+
createdAt: new Date().toISOString(),
|
|
238
|
+
createdByPid: 1,
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
fs.writeFileSync(
|
|
242
|
+
path.join(paths.pidsDir, '1.json'),
|
|
243
|
+
JSON.stringify({ pid: 1 })
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const orphaned = await sessionManager.detectOrphanedSessions();
|
|
247
|
+
|
|
248
|
+
expect(orphaned.size).toBe(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should clean stale session dirs without backup.json', async () => {
|
|
252
|
+
const hash = 'staleproject1';
|
|
253
|
+
const paths = projectManager.getProjectPaths(hash);
|
|
254
|
+
|
|
255
|
+
// Create session dir without backup.json
|
|
256
|
+
fs.mkdirSync(paths.pidsDir, { recursive: true });
|
|
257
|
+
|
|
258
|
+
const orphaned = await sessionManager.detectOrphanedSessions();
|
|
259
|
+
|
|
260
|
+
expect(orphaned.size).toBe(0);
|
|
261
|
+
// Stale directory should be cleaned up
|
|
262
|
+
expect(fs.existsSync(paths.sessionDir)).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// --- finalizeSessionCleanup ---
|
|
267
|
+
|
|
268
|
+
describe('finalizeSessionCleanup()', () => {
|
|
269
|
+
it('should remove session directory and archive to history', async () => {
|
|
270
|
+
const hash = 'finalize1';
|
|
271
|
+
const paths = projectManager.getProjectPaths(hash);
|
|
272
|
+
|
|
273
|
+
// Create session with backup.json
|
|
274
|
+
fs.mkdirSync(paths.pidsDir, { recursive: true });
|
|
275
|
+
fs.writeFileSync(
|
|
276
|
+
paths.backupRefFile,
|
|
277
|
+
JSON.stringify({
|
|
278
|
+
sessionId: 'session-finalize',
|
|
279
|
+
backupPath: '/tmp/backup',
|
|
280
|
+
projectPath: '/tmp/project',
|
|
281
|
+
target: 'claude-code',
|
|
282
|
+
createdAt: new Date().toISOString(),
|
|
283
|
+
createdByPid: process.pid,
|
|
284
|
+
})
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
await sessionManager.finalizeSessionCleanup(hash);
|
|
288
|
+
|
|
289
|
+
// Session dir should be gone
|
|
290
|
+
expect(fs.existsSync(paths.sessionDir)).toBe(false);
|
|
291
|
+
|
|
292
|
+
// History file should exist
|
|
293
|
+
const historyPath = path.join(flowHome, 'sessions', 'history', 'session-finalize.json');
|
|
294
|
+
expect(fs.existsSync(historyPath)).toBe(true);
|
|
295
|
+
const archived = JSON.parse(fs.readFileSync(historyPath, 'utf-8'));
|
|
296
|
+
expect(archived.status).toBe('completed');
|
|
297
|
+
expect(archived.sessionId).toBe('session-finalize');
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// --- getBackupRef ---
|
|
302
|
+
|
|
303
|
+
describe('getBackupRef()', () => {
|
|
304
|
+
it('should return null for non-existent session', async () => {
|
|
305
|
+
const ref = await sessionManager.getBackupRef('nonexistent');
|
|
306
|
+
expect(ref).toBeNull();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should return backup ref when exists', async () => {
|
|
310
|
+
const hash = 'reftest1';
|
|
311
|
+
const paths = projectManager.getProjectPaths(hash);
|
|
312
|
+
|
|
313
|
+
fs.mkdirSync(paths.sessionDir, { recursive: true });
|
|
314
|
+
fs.writeFileSync(
|
|
315
|
+
paths.backupRefFile,
|
|
316
|
+
JSON.stringify({
|
|
317
|
+
sessionId: 'session-ref',
|
|
318
|
+
backupPath: '/tmp/backup',
|
|
319
|
+
projectPath: '/tmp/project',
|
|
320
|
+
target: 'claude-code',
|
|
321
|
+
createdAt: new Date().toISOString(),
|
|
322
|
+
createdByPid: 1234,
|
|
323
|
+
})
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const ref = await sessionManager.getBackupRef(hash);
|
|
327
|
+
expect(ref?.sessionId).toBe('session-ref');
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// --- isSessionActive ---
|
|
332
|
+
|
|
333
|
+
describe('isSessionActive()', () => {
|
|
334
|
+
it('should return false when no backup.json', async () => {
|
|
335
|
+
const active = await sessionManager.isSessionActive('nonexistent');
|
|
336
|
+
expect(active).toBe(false);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should return true when backup.json exists and alive PIDs', async () => {
|
|
340
|
+
const hash = 'active1';
|
|
341
|
+
const paths = projectManager.getProjectPaths(hash);
|
|
342
|
+
|
|
343
|
+
fs.mkdirSync(paths.pidsDir, { recursive: true });
|
|
344
|
+
fs.writeFileSync(paths.backupRefFile, JSON.stringify({ sessionId: 'test' }));
|
|
345
|
+
// PID 1 is always alive
|
|
346
|
+
fs.writeFileSync(path.join(paths.pidsDir, '1.json'), JSON.stringify({ pid: 1 }));
|
|
347
|
+
|
|
348
|
+
const active = await sessionManager.isSessionActive(hash);
|
|
349
|
+
expect(active).toBe(true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should return false when backup.json exists but no alive PIDs', async () => {
|
|
353
|
+
const hash = 'dead1';
|
|
354
|
+
const paths = projectManager.getProjectPaths(hash);
|
|
355
|
+
|
|
356
|
+
fs.mkdirSync(paths.pidsDir, { recursive: true });
|
|
357
|
+
fs.writeFileSync(paths.backupRefFile, JSON.stringify({ sessionId: 'test' }));
|
|
358
|
+
// Dead PID
|
|
359
|
+
fs.writeFileSync(path.join(paths.pidsDir, '999999.json'), JSON.stringify({ pid: 999999 }));
|
|
360
|
+
|
|
361
|
+
const active = await sessionManager.isSessionActive(hash);
|
|
362
|
+
expect(active).toBe(false);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// --- cleanupSessionHistory (unchanged) ---
|
|
367
|
+
|
|
42
368
|
describe('cleanupSessionHistory()', () => {
|
|
43
369
|
it('should keep only last N session history files', async () => {
|
|
44
370
|
const historyDir = path.join(flowHome, 'sessions', 'history');
|
|
@@ -112,36 +438,4 @@ describe('SessionManager', () => {
|
|
|
112
438
|
expect(remaining).toContain('notes.txt');
|
|
113
439
|
});
|
|
114
440
|
});
|
|
115
|
-
|
|
116
|
-
describe('Session lifecycle', () => {
|
|
117
|
-
it('should create and end a session', async () => {
|
|
118
|
-
const projectPath = path.join(tempDir, 'project');
|
|
119
|
-
fs.mkdirSync(projectPath, { recursive: true });
|
|
120
|
-
|
|
121
|
-
const hash = projectManager.getProjectHash(projectPath);
|
|
122
|
-
|
|
123
|
-
const { session, isFirstSession } = await sessionManager.startSession(
|
|
124
|
-
projectPath,
|
|
125
|
-
hash,
|
|
126
|
-
'claude-code',
|
|
127
|
-
'/tmp/backup',
|
|
128
|
-
'session-test-1'
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
expect(isFirstSession).toBe(true);
|
|
132
|
-
expect(session.projectPath).toBe(projectPath);
|
|
133
|
-
expect(session.sessionId).toBe('session-test-1');
|
|
134
|
-
expect(session.refCount).toBe(1);
|
|
135
|
-
|
|
136
|
-
// Verify active session exists
|
|
137
|
-
const active = await sessionManager.getActiveSession(hash);
|
|
138
|
-
expect(active).not.toBeNull();
|
|
139
|
-
expect(active?.sessionId).toBe('session-test-1');
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('should return null for non-existent session', async () => {
|
|
143
|
-
const active = await sessionManager.getActiveSession('nonexistent');
|
|
144
|
-
expect(active).toBeNull();
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
441
|
});
|
|
@@ -149,7 +149,16 @@ export class BackupManager {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
|
-
* Restore backup to project
|
|
152
|
+
* Restore backup to project (atomic).
|
|
153
|
+
*
|
|
154
|
+
* Uses copy-to-temp → rm → rename pattern to ensure atomicity:
|
|
155
|
+
* 1. Copy backup → temp dir (.claude.flow-restore-<timestamp>)
|
|
156
|
+
* 2. rm -rf current config dir
|
|
157
|
+
* 3. fs.rename(temp, configDir) — atomic on same filesystem
|
|
158
|
+
*
|
|
159
|
+
* If the process dies after step 2 but before step 3, the temp dir
|
|
160
|
+
* still exists. cleanupOrphanedRestores() detects this on next startup
|
|
161
|
+
* and renames it into place as recovery.
|
|
153
162
|
*/
|
|
154
163
|
async restoreBackup(projectHash: string, sessionId: string): Promise<void> {
|
|
155
164
|
const paths = this.projectManager.getProjectPaths(projectHash);
|
|
@@ -169,19 +178,56 @@ export class BackupManager {
|
|
|
169
178
|
// Resolve target to get config
|
|
170
179
|
const target = resolveTarget(targetId);
|
|
171
180
|
|
|
172
|
-
// Get target config directory
|
|
181
|
+
// Get target config directory (e.g., /project/.claude)
|
|
173
182
|
const targetConfigDir = this.projectManager.getTargetConfigDir(projectPath, target);
|
|
183
|
+
const backupTargetDir = path.join(backupPath, target.config.configDir);
|
|
174
184
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
if (!existsSync(backupTargetDir)) {
|
|
186
|
+
// No backup config to restore — just remove current config
|
|
187
|
+
if (existsSync(targetConfigDir)) {
|
|
188
|
+
await fs.rm(targetConfigDir, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
178
191
|
}
|
|
179
192
|
|
|
180
|
-
//
|
|
181
|
-
const
|
|
193
|
+
// Atomic restore: copy → rm → rename
|
|
194
|
+
const tempDir = path.join(
|
|
195
|
+
path.dirname(targetConfigDir),
|
|
196
|
+
`${path.basename(targetConfigDir)}.flow-restore-${Date.now()}`
|
|
197
|
+
);
|
|
182
198
|
|
|
183
|
-
|
|
184
|
-
|
|
199
|
+
try {
|
|
200
|
+
// 1. Copy backup to temp dir
|
|
201
|
+
await this.copyDirectory(backupTargetDir, tempDir);
|
|
202
|
+
|
|
203
|
+
// 2. Remove current config dir
|
|
204
|
+
if (existsSync(targetConfigDir)) {
|
|
205
|
+
await fs.rm(targetConfigDir, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 3. Rename temp → config dir (atomic on same filesystem)
|
|
209
|
+
try {
|
|
210
|
+
await fs.rename(tempDir, targetConfigDir);
|
|
211
|
+
} catch (renameError: unknown) {
|
|
212
|
+
// EXDEV: cross-device link — source and dest on different filesystems.
|
|
213
|
+
// Fall back to copy + delete (not atomic, but functional).
|
|
214
|
+
if (renameError instanceof Error && 'code' in renameError && (renameError as NodeJS.ErrnoException).code === 'EXDEV') {
|
|
215
|
+
await this.copyDirectory(tempDir, targetConfigDir);
|
|
216
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
217
|
+
} else {
|
|
218
|
+
throw renameError;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
// Clean up temp dir on failure
|
|
223
|
+
try {
|
|
224
|
+
if (existsSync(tempDir)) {
|
|
225
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
// Ignore cleanup errors
|
|
229
|
+
}
|
|
230
|
+
throw error;
|
|
185
231
|
}
|
|
186
232
|
}
|
|
187
233
|
|
|
@@ -214,6 +260,50 @@ export class BackupManager {
|
|
|
214
260
|
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
|
215
261
|
}
|
|
216
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Clean up orphaned .flow-restore-* temp directories left by interrupted restores.
|
|
265
|
+
* If the process dies between rm(.claude) and rename(temp, .claude), the temp dir
|
|
266
|
+
* is stranded. This scans known project paths and removes any orphaned temp dirs.
|
|
267
|
+
*
|
|
268
|
+
* If the config dir is also missing (both deleted), the temp dir IS the backup —
|
|
269
|
+
* rename it into place as recovery.
|
|
270
|
+
*/
|
|
271
|
+
async cleanupOrphanedRestores(projectPath: string, targetOrId: Target | string): Promise<void> {
|
|
272
|
+
const target = resolveTargetOrId(targetOrId);
|
|
273
|
+
const targetConfigDir = this.projectManager.getTargetConfigDir(projectPath, target);
|
|
274
|
+
const parentDir = path.dirname(targetConfigDir);
|
|
275
|
+
const configBaseName = path.basename(targetConfigDir);
|
|
276
|
+
|
|
277
|
+
if (!existsSync(parentDir)) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const entries = await fs.readdir(parentDir);
|
|
283
|
+
const orphanedRestores = entries.filter(
|
|
284
|
+
(e) => e.startsWith(`${configBaseName}.flow-restore-`)
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
for (const orphan of orphanedRestores) {
|
|
288
|
+
const orphanPath = path.join(parentDir, orphan);
|
|
289
|
+
if (!existsSync(targetConfigDir)) {
|
|
290
|
+
// Config dir is gone — this temp dir IS the recovery. Rename it in.
|
|
291
|
+
try {
|
|
292
|
+
await fs.rename(orphanPath, targetConfigDir);
|
|
293
|
+
} catch {
|
|
294
|
+
// If rename fails, remove it
|
|
295
|
+
await fs.rm(orphanPath, { recursive: true, force: true }).catch(() => {});
|
|
296
|
+
}
|
|
297
|
+
} else {
|
|
298
|
+
// Config dir exists — temp dir is a leftover. Remove it.
|
|
299
|
+
await fs.rm(orphanPath, { recursive: true, force: true }).catch(() => {});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
} catch {
|
|
303
|
+
// Non-fatal — project dir might not exist
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
217
307
|
/**
|
|
218
308
|
* Cleanup old backups (keep last N)
|
|
219
309
|
*/
|