@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.
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Tests for SessionManager cleanup functionality
3
- * Covers: cleanupSessionHistory, multi-session lifecycle
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
- // Remove current target directory
176
- if (existsSync(targetConfigDir)) {
177
- await fs.rm(targetConfigDir, { recursive: true, force: true });
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
- // Restore from backup using target config's configDir
181
- const backupTargetDir = path.join(backupPath, target.config.configDir);
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
- if (existsSync(backupTargetDir)) {
184
- await this.copyDirectory(backupTargetDir, targetConfigDir);
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
  */