@sylphx/flow 3.19.1 → 3.21.0
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/assets/agents/builder.md +12 -3
- package/package.json +1 -1
- package/src/commands/flow/execute-v2.ts +19 -1
- package/src/commands/hook-command.ts +161 -20
- 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 +2 -3
- package/src/targets/functional/claude-code-logic.ts +11 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @sylphx/flow
|
|
2
2
|
|
|
3
|
+
## 3.21.0 (2026-02-08)
|
|
4
|
+
|
|
5
|
+
Add SessionStart memory hook and use stdin for notifications
|
|
6
|
+
|
|
7
|
+
### ✨ Features
|
|
8
|
+
|
|
9
|
+
- **flow:** replace state-flag session management with PID-based ground truth ([692344d](https://github.com/SylphxAI/flow/commit/692344d7cd2b2b8c3b460347b3111ac5b08e375a))
|
|
10
|
+
|
|
11
|
+
### 🐛 Bug Fixes
|
|
12
|
+
|
|
13
|
+
- close three gaps found in challenge round 2 ([52bf789](https://github.com/SylphxAI/flow/commit/52bf789323a30d3a245375bdd4442a2532984823))
|
|
14
|
+
|
|
15
|
+
### ♻️ Refactoring
|
|
16
|
+
|
|
17
|
+
- extract restoreAndFinalize helper, improve cleanup safety ([959f255](https://github.com/SylphxAI/flow/commit/959f25507fb9db78191c7ed3e540319948e1288c))
|
|
18
|
+
|
|
19
|
+
### 💅 Styles
|
|
20
|
+
|
|
21
|
+
- format package.json with biome (tabs → spaces) ([8560a3f](https://github.com/SylphxAI/flow/commit/8560a3f7c5debf220bf6fd02da19276911e75886))
|
|
22
|
+
|
|
23
|
+
## 3.20.0 (2026-02-08)
|
|
24
|
+
|
|
25
|
+
### ✨ Features
|
|
26
|
+
|
|
27
|
+
- **flow:** add SessionStart memory hook and use stdin for notifications ([6580500](https://github.com/SylphxAI/flow/commit/65805004f164a8f9a05d821e528c7412f7573e2f))
|
|
28
|
+
|
|
3
29
|
## 3.19.1 (2026-02-07)
|
|
4
30
|
|
|
5
31
|
### 🐛 Bug Fixes
|
package/assets/agents/builder.md
CHANGED
|
@@ -101,13 +101,22 @@ State-of-the-art industrial standard. Every time. Would you stake your reputatio
|
|
|
101
101
|
|
|
102
102
|
## Memory
|
|
103
103
|
|
|
104
|
+
Two-layer durable memory:
|
|
105
|
+
|
|
106
|
+
- **`MEMORY.md`** — Curated long-term memory. Decisions, preferences, durable facts.
|
|
107
|
+
- **`memory/YYYY-MM-DD.md`** — Daily log (append-only). Running context, day-to-day notes.
|
|
108
|
+
|
|
109
|
+
**Rules:**
|
|
110
|
+
- If someone says "remember this," write it down immediately (do not keep it in RAM).
|
|
111
|
+
- Decisions and preferences → `MEMORY.md`
|
|
112
|
+
- Day-to-day notes and running context → `memory/YYYY-MM-DD.md`
|
|
113
|
+
- SessionStart hook auto-loads MEMORY.md + today/yesterday daily logs.
|
|
114
|
+
|
|
104
115
|
**Atomic commits.** Commit continuously. Each commit = one logical change. Semantic commit messages (feat, fix, docs, refactor, test, chore). This is your memory of what was done.
|
|
105
116
|
|
|
106
117
|
**Todos.** Use TaskCreate/TaskUpdate to track what needs to be done. This is your memory of what to do.
|
|
107
118
|
|
|
108
|
-
**
|
|
109
|
-
|
|
110
|
-
**Recovery:** Lost context? → `git log`. Forgot next steps? → TaskList.
|
|
119
|
+
**Recovery:** Lost context? → `git log`. Forgot next steps? → TaskList. Need old memories? → read `memory/` directory.
|
|
111
120
|
|
|
112
121
|
## Issue Ownership
|
|
113
122
|
|
package/package.json
CHANGED
|
@@ -308,7 +308,25 @@ export async function executeFlowV2(
|
|
|
308
308
|
continue: options.continue,
|
|
309
309
|
};
|
|
310
310
|
|
|
311
|
-
|
|
311
|
+
// Suppress SIGINT in parent — child process handles its own Ctrl+C.
|
|
312
|
+
// Without this, SIGINT propagates to parent and triggers premature cleanup
|
|
313
|
+
// while the child is still running.
|
|
314
|
+
const originalSigintListeners = process.listeners('SIGINT') as ((...args: unknown[]) => void)[];
|
|
315
|
+
process.removeAllListeners('SIGINT');
|
|
316
|
+
process.on('SIGINT', () => {
|
|
317
|
+
// Intentionally empty — child handles Ctrl+C
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
await executeTargetCommand(selectedTargetId, systemPrompt, userPrompt, runOptions);
|
|
322
|
+
} finally {
|
|
323
|
+
// Restore original SIGINT listeners after child exits
|
|
324
|
+
process.removeAllListeners('SIGINT');
|
|
325
|
+
for (const listener of originalSigintListeners) {
|
|
326
|
+
process.on('SIGINT', listener);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
312
330
|
await executor.cleanup(projectPath);
|
|
313
331
|
} catch (error) {
|
|
314
332
|
if (error instanceof UserCancelledError) {
|
|
@@ -1,27 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Hook command -
|
|
3
|
+
* Hook command - Claude Code hook handlers
|
|
4
4
|
*
|
|
5
|
-
* Purpose:
|
|
5
|
+
* Purpose: Handle Claude Code lifecycle hooks:
|
|
6
|
+
* - notification: OS-level notification when Claude Code starts
|
|
7
|
+
* - session-start: Load durable memory (MEMORY.md + recent daily logs)
|
|
6
8
|
*
|
|
7
9
|
* DESIGN RATIONALE:
|
|
8
|
-
* -
|
|
9
|
-
* - Cross-platform
|
|
10
|
-
* -
|
|
10
|
+
* - Each hook type returns content to stdout (Claude Code injects as context)
|
|
11
|
+
* - Cross-platform notifications (macOS, Linux, Windows)
|
|
12
|
+
* - SessionStart hook loads cross-session memory at startup
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import { exec } from 'node:child_process';
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
14
17
|
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
15
19
|
import { promisify } from 'node:util';
|
|
16
20
|
import { Command } from 'commander';
|
|
17
21
|
import { cli } from '../utils/display/cli-output.js';
|
|
18
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Hook input from Claude Code via stdin.
|
|
25
|
+
* Claude Code sends JSON context about the event — we read what we need.
|
|
26
|
+
*/
|
|
27
|
+
interface HookInput {
|
|
28
|
+
/** Notification message text */
|
|
29
|
+
message?: string;
|
|
30
|
+
/** Notification title */
|
|
31
|
+
title?: string;
|
|
32
|
+
/** Notification type (permission_prompt, idle_prompt, etc.) */
|
|
33
|
+
notification_type?: string;
|
|
34
|
+
/** How the session started (startup, resume, clear, compact) */
|
|
35
|
+
source?: string;
|
|
36
|
+
/** Current working directory */
|
|
37
|
+
cwd?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
19
40
|
const execAsync = promisify(exec);
|
|
20
41
|
|
|
21
42
|
/**
|
|
22
|
-
* Hook types supported
|
|
43
|
+
* Hook types supported — single source of truth for both type and runtime validation
|
|
23
44
|
*/
|
|
24
|
-
|
|
45
|
+
const VALID_HOOK_TYPES = ['notification', 'session-start'] as const;
|
|
46
|
+
type HookType = (typeof VALID_HOOK_TYPES)[number];
|
|
47
|
+
const VALID_HOOK_TYPE_SET = new Set<string>(VALID_HOOK_TYPES);
|
|
25
48
|
|
|
26
49
|
/**
|
|
27
50
|
* Target platforms supported
|
|
@@ -32,18 +55,20 @@ type TargetPlatform = 'claude-code';
|
|
|
32
55
|
* Create the hook command
|
|
33
56
|
*/
|
|
34
57
|
export const hookCommand = new Command('hook')
|
|
35
|
-
.description('
|
|
36
|
-
.requiredOption('--type <type>', 'Hook type (notification)')
|
|
58
|
+
.description('Handle Claude Code lifecycle hooks (notification, memory)')
|
|
59
|
+
.requiredOption('--type <type>', 'Hook type (notification | session-start)')
|
|
37
60
|
.option('--target <target>', 'Target platform (claude-code)', 'claude-code')
|
|
38
61
|
.option('--verbose', 'Show verbose output', false)
|
|
39
62
|
.action(async (options) => {
|
|
40
63
|
try {
|
|
41
|
-
const hookType = options.type as
|
|
64
|
+
const hookType = options.type as string;
|
|
42
65
|
const target = options.target as TargetPlatform;
|
|
43
66
|
|
|
44
67
|
// Validate hook type
|
|
45
|
-
if (hookType
|
|
46
|
-
throw new Error(
|
|
68
|
+
if (!VALID_HOOK_TYPE_SET.has(hookType)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Invalid hook type: ${hookType}. Must be one of: ${VALID_HOOK_TYPES.join(', ')}`
|
|
71
|
+
);
|
|
47
72
|
}
|
|
48
73
|
|
|
49
74
|
// Validate target
|
|
@@ -51,8 +76,11 @@ export const hookCommand = new Command('hook')
|
|
|
51
76
|
throw new Error(`Invalid target: ${target}. Only 'claude-code' is currently supported`);
|
|
52
77
|
}
|
|
53
78
|
|
|
79
|
+
// Read hook input from stdin (Claude Code passes JSON context)
|
|
80
|
+
const hookInput = await readStdinInput();
|
|
81
|
+
|
|
54
82
|
// Load and display content based on hook type
|
|
55
|
-
const content = await loadHookContent(hookType, target, options.verbose);
|
|
83
|
+
const content = await loadHookContent(hookType as HookType, target, hookInput, options.verbose);
|
|
56
84
|
|
|
57
85
|
// Output the content (no extra formatting, just the content)
|
|
58
86
|
console.log(content);
|
|
@@ -72,27 +100,140 @@ export const hookCommand = new Command('hook')
|
|
|
72
100
|
}
|
|
73
101
|
});
|
|
74
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Read JSON input from stdin (non-blocking, returns empty object if no input)
|
|
105
|
+
* Claude Code sends event context as JSON on stdin for all hook types.
|
|
106
|
+
*/
|
|
107
|
+
async function readStdinInput(): Promise<HookInput> {
|
|
108
|
+
// stdin is not a TTY when piped from Claude Code
|
|
109
|
+
if (process.stdin.isTTY) {
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
let data = '';
|
|
115
|
+
process.stdin.setEncoding('utf-8');
|
|
116
|
+
process.stdin.on('data', (chunk) => {
|
|
117
|
+
data += chunk;
|
|
118
|
+
});
|
|
119
|
+
process.stdin.on('end', () => {
|
|
120
|
+
if (!data.trim()) {
|
|
121
|
+
resolve({});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
resolve(JSON.parse(data) as HookInput);
|
|
126
|
+
} catch {
|
|
127
|
+
resolve({});
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
// Safety timeout — don't hang if stdin never closes
|
|
131
|
+
setTimeout(() => resolve({}), 1000);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
75
135
|
/**
|
|
76
136
|
* Load content for a specific hook type and target
|
|
77
137
|
*/
|
|
78
138
|
async function loadHookContent(
|
|
79
139
|
hookType: HookType,
|
|
80
140
|
_target: TargetPlatform,
|
|
141
|
+
input: HookInput,
|
|
81
142
|
verbose: boolean = false
|
|
82
143
|
): Promise<string> {
|
|
83
|
-
|
|
84
|
-
|
|
144
|
+
switch (hookType) {
|
|
145
|
+
case 'notification':
|
|
146
|
+
return await sendNotification(input, verbose);
|
|
147
|
+
case 'session-start':
|
|
148
|
+
return await loadSessionStartContent(verbose);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read a file and return its contents, or empty string if not found
|
|
154
|
+
*/
|
|
155
|
+
async function readFileIfExists(filePath: string): Promise<string> {
|
|
156
|
+
try {
|
|
157
|
+
return await readFile(filePath, 'utf-8');
|
|
158
|
+
} catch {
|
|
159
|
+
return '';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Format a date as YYYY-MM-DD
|
|
165
|
+
*/
|
|
166
|
+
function formatDate(date: Date): string {
|
|
167
|
+
const year = date.getFullYear();
|
|
168
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
169
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
170
|
+
return `${year}-${month}-${day}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Load memory files for session start:
|
|
175
|
+
* - MEMORY.md (curated long-term memory)
|
|
176
|
+
* - memory/{today}.md (today's daily log)
|
|
177
|
+
* - memory/{yesterday}.md (yesterday's daily log)
|
|
178
|
+
*
|
|
179
|
+
* Returns concatenated content to stdout for Claude Code context injection
|
|
180
|
+
*/
|
|
181
|
+
async function loadSessionStartContent(verbose: boolean): Promise<string> {
|
|
182
|
+
const cwd = process.cwd();
|
|
183
|
+
const sections: string[] = [];
|
|
184
|
+
|
|
185
|
+
// Load MEMORY.md
|
|
186
|
+
const memoryPath = path.join(cwd, 'MEMORY.md');
|
|
187
|
+
const memoryContent = await readFileIfExists(memoryPath);
|
|
188
|
+
if (memoryContent.trim()) {
|
|
189
|
+
sections.push(`## MEMORY.md\n${memoryContent.trim()}`);
|
|
190
|
+
if (verbose) {
|
|
191
|
+
cli.info('Loaded MEMORY.md');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Calculate today and yesterday dates
|
|
196
|
+
const today = new Date();
|
|
197
|
+
const yesterday = new Date(today);
|
|
198
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
199
|
+
|
|
200
|
+
const todayStr = formatDate(today);
|
|
201
|
+
const yesterdayStr = formatDate(yesterday);
|
|
202
|
+
|
|
203
|
+
// Load today's daily log
|
|
204
|
+
const todayPath = path.join(cwd, 'memory', `${todayStr}.md`);
|
|
205
|
+
const todayContent = await readFileIfExists(todayPath);
|
|
206
|
+
if (todayContent.trim()) {
|
|
207
|
+
sections.push(`## memory/${todayStr}.md\n${todayContent.trim()}`);
|
|
208
|
+
if (verbose) {
|
|
209
|
+
cli.info(`Loaded memory/${todayStr}.md`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Load yesterday's daily log
|
|
214
|
+
const yesterdayPath = path.join(cwd, 'memory', `${yesterdayStr}.md`);
|
|
215
|
+
const yesterdayContent = await readFileIfExists(yesterdayPath);
|
|
216
|
+
if (yesterdayContent.trim()) {
|
|
217
|
+
sections.push(`## memory/${yesterdayStr}.md\n${yesterdayContent.trim()}`);
|
|
218
|
+
if (verbose) {
|
|
219
|
+
cli.info(`Loaded memory/${yesterdayStr}.md`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (verbose && sections.length === 0) {
|
|
224
|
+
cli.info('No memory files found');
|
|
85
225
|
}
|
|
86
226
|
|
|
87
|
-
return '';
|
|
227
|
+
return sections.join('\n\n');
|
|
88
228
|
}
|
|
89
229
|
|
|
90
230
|
/**
|
|
91
|
-
* Send OS-level notification
|
|
231
|
+
* Send OS-level notification using event data from Claude Code.
|
|
232
|
+
* Falls back to generic message when stdin input is missing.
|
|
92
233
|
*/
|
|
93
|
-
async function sendNotification(verbose: boolean): Promise<string> {
|
|
94
|
-
const title = '🔮 Sylphx Flow';
|
|
95
|
-
const message = 'Claude Code is ready';
|
|
234
|
+
async function sendNotification(input: HookInput, verbose: boolean): Promise<string> {
|
|
235
|
+
const title = input.title || '🔮 Sylphx Flow';
|
|
236
|
+
const message = input.message || 'Claude Code is ready';
|
|
96
237
|
const platform = os.platform();
|
|
97
238
|
|
|
98
239
|
if (verbose) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for CleanupHandler
|
|
3
|
-
* Covers:
|
|
4
|
-
* periodic cleanup gating,
|
|
3
|
+
* Covers: cleanup flow, crash recovery, orphaned project detection,
|
|
4
|
+
* periodic cleanup gating, finalize-after-restore ordering,
|
|
5
|
+
* and legacy migration
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import fs from 'node:fs';
|
|
@@ -16,21 +17,26 @@ function createMockProjectManager(flowHome: string) {
|
|
|
16
17
|
return {
|
|
17
18
|
getFlowHomeDir: () => flowHome,
|
|
18
19
|
getProjectPaths: (hash: string) => ({
|
|
19
|
-
|
|
20
|
+
sessionDir: path.join(flowHome, 'sessions', hash),
|
|
21
|
+
backupRefFile: path.join(flowHome, 'sessions', hash, 'backup.json'),
|
|
22
|
+
pidsDir: path.join(flowHome, 'sessions', hash, 'pids'),
|
|
20
23
|
backupsDir: path.join(flowHome, 'backups', hash),
|
|
21
24
|
secretsDir: path.join(flowHome, 'secrets', hash),
|
|
22
25
|
latestBackup: path.join(flowHome, 'backups', hash, 'latest'),
|
|
23
26
|
}),
|
|
27
|
+
getActiveProjects: vi.fn().mockResolvedValue([]),
|
|
24
28
|
};
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
function createMockSessionManager() {
|
|
28
32
|
return {
|
|
29
33
|
detectOrphanedSessions: vi.fn().mockResolvedValue(new Map()),
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
releaseSession: vi.fn().mockResolvedValue({ shouldRestore: false, backupRef: null }),
|
|
35
|
+
finalizeSessionCleanup: vi.fn().mockResolvedValue(undefined),
|
|
36
|
+
isSessionActive: vi.fn().mockResolvedValue(false),
|
|
33
37
|
cleanupSessionHistory: vi.fn().mockResolvedValue(undefined),
|
|
38
|
+
getBackupRef: vi.fn().mockResolvedValue(null),
|
|
39
|
+
acquireSession: vi.fn().mockResolvedValue({ isFirstSession: false, backupRef: null }),
|
|
34
40
|
};
|
|
35
41
|
}
|
|
36
42
|
|
|
@@ -38,6 +44,7 @@ function createMockBackupManager() {
|
|
|
38
44
|
return {
|
|
39
45
|
restoreBackup: vi.fn().mockResolvedValue(undefined),
|
|
40
46
|
cleanupOldBackups: vi.fn().mockResolvedValue(undefined),
|
|
47
|
+
cleanupOrphanedRestores: vi.fn().mockResolvedValue(undefined),
|
|
41
48
|
};
|
|
42
49
|
}
|
|
43
50
|
|
|
@@ -94,24 +101,74 @@ describe('CleanupHandler', () => {
|
|
|
94
101
|
// --- cleanup() ---
|
|
95
102
|
|
|
96
103
|
describe('cleanup()', () => {
|
|
97
|
-
it('should restore backup and
|
|
98
|
-
const
|
|
99
|
-
|
|
104
|
+
it('should restore backup and finalize when last session ends', async () => {
|
|
105
|
+
const backupRef = {
|
|
106
|
+
sessionId: 'session-1',
|
|
107
|
+
projectPath: '/tmp/project',
|
|
108
|
+
backupPath: '/tmp/backup',
|
|
109
|
+
target: 'claude-code',
|
|
110
|
+
};
|
|
111
|
+
mockSessionManager.releaseSession.mockResolvedValue({ shouldRestore: true, backupRef });
|
|
100
112
|
|
|
101
113
|
await handler.cleanup('abc123');
|
|
102
114
|
|
|
103
115
|
expect(mockBackupManager.restoreBackup).toHaveBeenCalledWith('abc123', 'session-1');
|
|
116
|
+
// CRITICAL: finalize called AFTER restore
|
|
117
|
+
expect(mockSessionManager.finalizeSessionCleanup).toHaveBeenCalledWith('abc123');
|
|
104
118
|
expect(mockBackupManager.cleanupOldBackups).toHaveBeenCalledWith('abc123', 3);
|
|
105
119
|
expect(mockGitStash.popSettingsChanges).toHaveBeenCalledWith('/tmp/project');
|
|
106
120
|
expect(mockSecrets.clearSecrets).toHaveBeenCalledWith('abc123');
|
|
107
121
|
});
|
|
108
122
|
|
|
123
|
+
it('should call finalize AFTER restore (ordering)', async () => {
|
|
124
|
+
const callOrder: string[] = [];
|
|
125
|
+
mockSessionManager.releaseSession.mockResolvedValue({
|
|
126
|
+
shouldRestore: true,
|
|
127
|
+
backupRef: {
|
|
128
|
+
sessionId: 's1',
|
|
129
|
+
projectPath: '/tmp/p',
|
|
130
|
+
backupPath: '/tmp/b',
|
|
131
|
+
target: 'claude-code',
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
mockBackupManager.restoreBackup.mockImplementation(async () => {
|
|
135
|
+
callOrder.push('restore');
|
|
136
|
+
});
|
|
137
|
+
mockSessionManager.finalizeSessionCleanup.mockImplementation(async () => {
|
|
138
|
+
callOrder.push('finalize');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await handler.cleanup('abc123');
|
|
142
|
+
|
|
143
|
+
expect(callOrder).toEqual(['restore', 'finalize']);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should NOT call finalize if restore fails', async () => {
|
|
147
|
+
mockSessionManager.releaseSession.mockResolvedValue({
|
|
148
|
+
shouldRestore: true,
|
|
149
|
+
backupRef: {
|
|
150
|
+
sessionId: 's1',
|
|
151
|
+
projectPath: '/tmp/p',
|
|
152
|
+
backupPath: '/tmp/b',
|
|
153
|
+
target: 'claude-code',
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
mockBackupManager.restoreBackup.mockRejectedValue(new Error('restore failed'));
|
|
157
|
+
|
|
158
|
+
// cleanup() will throw since restoreBackup throws
|
|
159
|
+
await expect(handler.cleanup('abc123')).rejects.toThrow('restore failed');
|
|
160
|
+
|
|
161
|
+
// finalize should NOT have been called
|
|
162
|
+
expect(mockSessionManager.finalizeSessionCleanup).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
|
|
109
165
|
it('should not restore when other sessions still active', async () => {
|
|
110
|
-
mockSessionManager.
|
|
166
|
+
mockSessionManager.releaseSession.mockResolvedValue({ shouldRestore: false, backupRef: null });
|
|
111
167
|
|
|
112
168
|
await handler.cleanup('abc123');
|
|
113
169
|
|
|
114
170
|
expect(mockBackupManager.restoreBackup).not.toHaveBeenCalled();
|
|
171
|
+
expect(mockSessionManager.finalizeSessionCleanup).not.toHaveBeenCalled();
|
|
115
172
|
expect(mockGitStash.popSettingsChanges).not.toHaveBeenCalled();
|
|
116
173
|
expect(mockSecrets.clearSecrets).not.toHaveBeenCalled();
|
|
117
174
|
});
|
|
@@ -120,30 +177,31 @@ describe('CleanupHandler', () => {
|
|
|
120
177
|
// --- recoverOnStartup() ---
|
|
121
178
|
|
|
122
179
|
describe('recoverOnStartup()', () => {
|
|
123
|
-
it('should recover orphaned sessions', async () => {
|
|
124
|
-
const
|
|
180
|
+
it('should recover orphaned sessions with finalize after restore', async () => {
|
|
181
|
+
const backupRef = {
|
|
125
182
|
sessionId: 'session-crashed',
|
|
126
183
|
projectPath: '/tmp/crashed-project',
|
|
127
|
-
|
|
184
|
+
backupPath: '/tmp/backup',
|
|
185
|
+
target: 'claude-code',
|
|
128
186
|
};
|
|
129
|
-
const orphaned = new Map([['hash1',
|
|
187
|
+
const orphaned = new Map([['hash1', backupRef]]);
|
|
130
188
|
mockSessionManager.detectOrphanedSessions.mockResolvedValue(orphaned);
|
|
131
189
|
|
|
132
190
|
await handler.recoverOnStartup();
|
|
133
191
|
|
|
134
192
|
expect(mockBackupManager.restoreBackup).toHaveBeenCalledWith('hash1', 'session-crashed');
|
|
135
|
-
expect(mockSessionManager.
|
|
193
|
+
expect(mockSessionManager.finalizeSessionCleanup).toHaveBeenCalledWith('hash1');
|
|
136
194
|
expect(mockGitStash.popSettingsChanges).toHaveBeenCalledWith('/tmp/crashed-project');
|
|
137
195
|
expect(mockSecrets.clearSecrets).toHaveBeenCalledWith('hash1');
|
|
138
196
|
expect(mockBackupManager.cleanupOldBackups).toHaveBeenCalledWith('hash1', 3);
|
|
139
197
|
});
|
|
140
198
|
|
|
141
199
|
it('should handle multiple orphaned sessions independently', async () => {
|
|
142
|
-
const
|
|
143
|
-
const
|
|
200
|
+
const ref1 = { sessionId: 's1', projectPath: '/p1', backupPath: '/b1', target: 'claude-code' };
|
|
201
|
+
const ref2 = { sessionId: 's2', projectPath: '/p2', backupPath: '/b2', target: 'claude-code' };
|
|
144
202
|
const orphaned = new Map([
|
|
145
|
-
['h1',
|
|
146
|
-
['h2',
|
|
203
|
+
['h1', ref1],
|
|
204
|
+
['h2', ref2],
|
|
147
205
|
]);
|
|
148
206
|
mockSessionManager.detectOrphanedSessions.mockResolvedValue(orphaned);
|
|
149
207
|
|
|
@@ -156,7 +214,9 @@ describe('CleanupHandler', () => {
|
|
|
156
214
|
|
|
157
215
|
// Second session should still be recovered despite first failure
|
|
158
216
|
expect(mockBackupManager.restoreBackup).toHaveBeenCalledTimes(2);
|
|
159
|
-
expect(mockSessionManager.
|
|
217
|
+
expect(mockSessionManager.finalizeSessionCleanup).toHaveBeenCalledWith('h2');
|
|
218
|
+
// First session's finalize should NOT have been called (restore failed)
|
|
219
|
+
expect(mockSessionManager.finalizeSessionCleanup).not.toHaveBeenCalledWith('h1');
|
|
160
220
|
});
|
|
161
221
|
|
|
162
222
|
it('should skip heavy cleanup when marker is fresh', async () => {
|
|
@@ -209,6 +269,76 @@ describe('CleanupHandler', () => {
|
|
|
209
269
|
});
|
|
210
270
|
});
|
|
211
271
|
|
|
272
|
+
// --- Legacy migration ---
|
|
273
|
+
|
|
274
|
+
describe('migrateLegacySessions (via recoverOnStartup)', () => {
|
|
275
|
+
it('should migrate legacy session files with cleanupRequired=true', async () => {
|
|
276
|
+
// Create a legacy session file
|
|
277
|
+
const legacySession = {
|
|
278
|
+
projectHash: 'abc123',
|
|
279
|
+
projectPath: '/tmp/project',
|
|
280
|
+
sessionId: 'session-legacy',
|
|
281
|
+
pid: 999999,
|
|
282
|
+
startTime: '2026-01-01T00:00:00.000Z',
|
|
283
|
+
backupPath: '/tmp/backups/session-legacy',
|
|
284
|
+
status: 'active',
|
|
285
|
+
target: 'claude-code',
|
|
286
|
+
cleanupRequired: true,
|
|
287
|
+
isOriginal: true,
|
|
288
|
+
sharedBackupId: 'session-legacy',
|
|
289
|
+
refCount: 1,
|
|
290
|
+
activePids: [999999],
|
|
291
|
+
};
|
|
292
|
+
fs.writeFileSync(
|
|
293
|
+
path.join(flowHome, 'sessions', 'abc123.json'),
|
|
294
|
+
JSON.stringify(legacySession)
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// Fresh cleanup marker to skip heavy cleanup
|
|
298
|
+
fs.writeFileSync(path.join(flowHome, '.last-cleanup'), new Date().toISOString());
|
|
299
|
+
|
|
300
|
+
await handler.recoverOnStartup();
|
|
301
|
+
|
|
302
|
+
// Legacy file should be gone
|
|
303
|
+
expect(fs.existsSync(path.join(flowHome, 'sessions', 'abc123.json'))).toBe(false);
|
|
304
|
+
|
|
305
|
+
// New directory structure should exist with backup.json
|
|
306
|
+
const paths = mockProjectManager.getProjectPaths('abc123');
|
|
307
|
+
expect(fs.existsSync(paths.backupRefFile)).toBe(true);
|
|
308
|
+
|
|
309
|
+
const backupRef = JSON.parse(fs.readFileSync(paths.backupRefFile, 'utf-8'));
|
|
310
|
+
expect(backupRef.sessionId).toBe('session-legacy');
|
|
311
|
+
expect(backupRef.projectPath).toBe('/tmp/project');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should migrate legacy session files with cleanupRequired=false (no backup.json)', async () => {
|
|
315
|
+
const legacySession = {
|
|
316
|
+
sessionId: 'session-done',
|
|
317
|
+
backupPath: '/tmp/backup',
|
|
318
|
+
projectPath: '/tmp/project',
|
|
319
|
+
target: 'claude-code',
|
|
320
|
+
cleanupRequired: false,
|
|
321
|
+
pid: 999999,
|
|
322
|
+
startTime: '2026-01-01T00:00:00.000Z',
|
|
323
|
+
};
|
|
324
|
+
fs.writeFileSync(
|
|
325
|
+
path.join(flowHome, 'sessions', 'def456.json'),
|
|
326
|
+
JSON.stringify(legacySession)
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
fs.writeFileSync(path.join(flowHome, '.last-cleanup'), new Date().toISOString());
|
|
330
|
+
|
|
331
|
+
await handler.recoverOnStartup();
|
|
332
|
+
|
|
333
|
+
// Legacy file should be gone
|
|
334
|
+
expect(fs.existsSync(path.join(flowHome, 'sessions', 'def456.json'))).toBe(false);
|
|
335
|
+
|
|
336
|
+
// No backup.json should be created (cleanupRequired was false)
|
|
337
|
+
const paths = mockProjectManager.getProjectPaths('def456');
|
|
338
|
+
expect(fs.existsSync(paths.backupRefFile)).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
212
342
|
// --- Orphaned project cleanup ---
|
|
213
343
|
|
|
214
344
|
describe('cleanupOrphanedProjects (via recoverOnStartup)', () => {
|
|
@@ -248,11 +378,8 @@ describe('CleanupHandler', () => {
|
|
|
248
378
|
);
|
|
249
379
|
|
|
250
380
|
// Mock: this project has an active session
|
|
251
|
-
mockSessionManager.
|
|
252
|
-
|
|
253
|
-
return { projectHash: activeHash };
|
|
254
|
-
}
|
|
255
|
-
return null;
|
|
381
|
+
mockSessionManager.isSessionActive.mockImplementation(async (hash: string) => {
|
|
382
|
+
return hash === activeHash;
|
|
256
383
|
});
|
|
257
384
|
|
|
258
385
|
await handler.recoverOnStartup();
|