@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
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cleanup Handler
|
|
3
3
|
* Manages graceful cleanup on exit and crash recovery
|
|
4
|
-
* Handles process signals and ensures backup restoration
|
|
5
4
|
*
|
|
6
5
|
* Centralized cleanup for all exit paths:
|
|
7
|
-
* - Signal (
|
|
8
|
-
* - Manual (FlowExecutor.cleanup) → cleanup() → restore +
|
|
9
|
-
* - Crash recovery (next startup) → recoverOnStartup() → restore +
|
|
6
|
+
* - Signal (SIGTERM) → onSignal() → release + restore + finalize
|
|
7
|
+
* - Manual (FlowExecutor.cleanup) → cleanup() → release + restore + finalize
|
|
8
|
+
* - Crash recovery (next startup) → recoverOnStartup() → restore + finalize
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* SIGINT is NOT handled — the child process (claude-code) handles its own Ctrl+C.
|
|
11
|
+
* Suppression is done in execute-v2.ts around the child spawn.
|
|
12
|
+
*
|
|
13
|
+
* CRITICAL: backup.json is deleted AFTER restore succeeds (via finalizeSessionCleanup),
|
|
14
|
+
* ensuring orphan detection always works even if the process dies mid-cleanup.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import { existsSync } from 'node:fs';
|
|
@@ -51,7 +52,8 @@ export class CleanupHandler {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
|
-
* Register cleanup hooks for current project
|
|
55
|
+
* Register cleanup hooks for current project.
|
|
56
|
+
* SIGINT is intentionally NOT handled — child handles Ctrl+C.
|
|
55
57
|
*/
|
|
56
58
|
registerCleanupHooks(projectHash: string): void {
|
|
57
59
|
if (this.registered) {
|
|
@@ -61,43 +63,33 @@ export class CleanupHandler {
|
|
|
61
63
|
this.currentProjectHash = projectHash;
|
|
62
64
|
this.registered = true;
|
|
63
65
|
|
|
64
|
-
//
|
|
65
|
-
process.on('SIGINT', () => {
|
|
66
|
-
this.handleSignal('SIGINT', 0);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
// SIGTERM - perform async cleanup then exit
|
|
66
|
+
// SIGTERM — perform async cleanup then exit
|
|
70
67
|
process.on('SIGTERM', () => {
|
|
71
68
|
this.handleSignal('SIGTERM', 0);
|
|
72
69
|
});
|
|
73
70
|
|
|
74
|
-
// Uncaught exceptions
|
|
71
|
+
// Uncaught exceptions — perform async cleanup then exit with error
|
|
75
72
|
process.on('uncaughtException', (error) => {
|
|
76
73
|
console.error('\nUncaught Exception:', error);
|
|
77
74
|
this.handleSignal('uncaughtException', 1);
|
|
78
75
|
});
|
|
79
76
|
|
|
80
|
-
// Unhandled rejections
|
|
77
|
+
// Unhandled rejections — perform async cleanup then exit with error
|
|
81
78
|
process.on('unhandledRejection', (reason) => {
|
|
82
79
|
console.error('\nUnhandled Rejection:', reason);
|
|
83
80
|
this.handleSignal('unhandledRejection', 1);
|
|
84
81
|
});
|
|
85
82
|
|
|
86
|
-
// 'exit' handler
|
|
87
|
-
// This catches cases where process.exit() was called without going through signals
|
|
83
|
+
// 'exit' handler — SYNC ONLY, last resort safety net
|
|
88
84
|
process.on('exit', () => {
|
|
89
|
-
// If cleanup wasn't done via signal handlers,
|
|
90
|
-
//
|
|
91
|
-
if (!this.cleanupCompleted && this.currentProjectHash) {
|
|
92
|
-
// Recovery will happen on next startup via recoverOnStartup()
|
|
93
|
-
// This is the intended safety net for abnormal exits
|
|
94
|
-
}
|
|
85
|
+
// If cleanup wasn't done via signal handlers, orphan recovery
|
|
86
|
+
// will happen on next startup via recoverOnStartup()
|
|
95
87
|
});
|
|
96
88
|
}
|
|
97
89
|
|
|
98
90
|
/**
|
|
99
|
-
* Handle signal with async cleanup
|
|
100
|
-
* Ensures cleanup completes before process exit
|
|
91
|
+
* Handle signal with async cleanup.
|
|
92
|
+
* Ensures cleanup completes before process exit.
|
|
101
93
|
*/
|
|
102
94
|
private handleSignal(signal: string, exitCode: number): void {
|
|
103
95
|
// Prevent double cleanup
|
|
@@ -108,7 +100,6 @@ export class CleanupHandler {
|
|
|
108
100
|
|
|
109
101
|
this.cleanupInProgress = true;
|
|
110
102
|
|
|
111
|
-
// Perform async cleanup, then exit
|
|
112
103
|
this.onSignal(signal).finally(() => {
|
|
113
104
|
this.cleanupCompleted = true;
|
|
114
105
|
process.exit(exitCode);
|
|
@@ -116,8 +107,8 @@ export class CleanupHandler {
|
|
|
116
107
|
}
|
|
117
108
|
|
|
118
109
|
/**
|
|
119
|
-
* Async cleanup for signals
|
|
120
|
-
*
|
|
110
|
+
* Async cleanup for signals.
|
|
111
|
+
* Release session → restore if last → finalize AFTER restore.
|
|
121
112
|
*/
|
|
122
113
|
private async onSignal(_signal: string): Promise<void> {
|
|
123
114
|
if (!this.currentProjectHash) {
|
|
@@ -125,15 +116,12 @@ export class CleanupHandler {
|
|
|
125
116
|
}
|
|
126
117
|
|
|
127
118
|
try {
|
|
128
|
-
const { shouldRestore,
|
|
119
|
+
const { shouldRestore, backupRef } = await this.sessionManager.releaseSession(
|
|
129
120
|
this.currentProjectHash
|
|
130
121
|
);
|
|
131
122
|
|
|
132
|
-
if (shouldRestore &&
|
|
133
|
-
await this.
|
|
134
|
-
await this.backupManager.cleanupOldBackups(this.currentProjectHash, 3);
|
|
135
|
-
await this.gitStashManager.popSettingsChanges(session.projectPath);
|
|
136
|
-
await this.secretsManager.clearSecrets(this.currentProjectHash);
|
|
123
|
+
if (shouldRestore && backupRef) {
|
|
124
|
+
await this.restoreAndFinalize(this.currentProjectHash, backupRef);
|
|
137
125
|
}
|
|
138
126
|
} catch (error) {
|
|
139
127
|
debug('signal cleanup failed:', error);
|
|
@@ -141,24 +129,42 @@ export class CleanupHandler {
|
|
|
141
129
|
}
|
|
142
130
|
|
|
143
131
|
/**
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* Silent operation - no console output
|
|
132
|
+
* Restore backup and finalize session.
|
|
133
|
+
* Single source of truth for the restore → finalize → cleanup sequence.
|
|
147
134
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
135
|
+
* CRITICAL ordering: backup.json is deleted (via finalizeSessionCleanup) only
|
|
136
|
+
* AFTER restoreBackup succeeds. If restore throws, finalize is NOT called,
|
|
137
|
+
* ensuring orphan detection always works on next startup.
|
|
138
|
+
*/
|
|
139
|
+
private async restoreAndFinalize(
|
|
140
|
+
projectHash: string,
|
|
141
|
+
backupRef: { sessionId: string; projectPath: string; target?: string }
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
await this.backupManager.restoreBackup(projectHash, backupRef.sessionId);
|
|
144
|
+
await this.sessionManager.finalizeSessionCleanup(projectHash);
|
|
145
|
+
await this.backupManager.cleanupOldBackups(projectHash, 3);
|
|
146
|
+
// Clean up any orphaned .flow-restore-* temp dirs from interrupted restores
|
|
147
|
+
if (backupRef.target) {
|
|
148
|
+
await this.backupManager.cleanupOrphanedRestores(backupRef.projectPath, backupRef.target).catch(() => {});
|
|
149
|
+
}
|
|
150
|
+
await this.gitStashManager.popSettingsChanges(backupRef.projectPath);
|
|
151
|
+
await this.secretsManager.clearSecrets(projectHash);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Recover on startup (for all projects).
|
|
156
|
+
* Handles: orphaned sessions, legacy migration, periodic heavy cleanup.
|
|
150
157
|
*/
|
|
151
158
|
async recoverOnStartup(): Promise<void> {
|
|
159
|
+
// 0. Migrate legacy session files (old <hash>.json format → new directory format)
|
|
160
|
+
await this.migrateLegacySessions();
|
|
161
|
+
|
|
152
162
|
// 1. Always recover orphaned sessions (from crashes) — fast when none exist
|
|
153
163
|
const orphanedSessions = await this.sessionManager.detectOrphanedSessions();
|
|
154
164
|
|
|
155
|
-
for (const [projectHash,
|
|
165
|
+
for (const [projectHash, backupRef] of orphanedSessions) {
|
|
156
166
|
try {
|
|
157
|
-
await this.
|
|
158
|
-
await this.sessionManager.recoverSession(projectHash, session);
|
|
159
|
-
await this.gitStashManager.popSettingsChanges(session.projectPath);
|
|
160
|
-
await this.secretsManager.clearSecrets(projectHash);
|
|
161
|
-
await this.backupManager.cleanupOldBackups(projectHash, 3);
|
|
167
|
+
await this.restoreAndFinalize(projectHash, backupRef);
|
|
162
168
|
} catch (error) {
|
|
163
169
|
debug('startup recovery failed for session:', error);
|
|
164
170
|
}
|
|
@@ -178,6 +184,74 @@ export class CleanupHandler {
|
|
|
178
184
|
}
|
|
179
185
|
}
|
|
180
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Migrate legacy session files (old format: sessions/<hash>.json)
|
|
189
|
+
* to new directory format (sessions/<hash>/backup.json + pids/)
|
|
190
|
+
*/
|
|
191
|
+
private async migrateLegacySessions(): Promise<void> {
|
|
192
|
+
const sessionsDir = path.join(this.projectManager.getFlowHomeDir(), 'sessions');
|
|
193
|
+
|
|
194
|
+
if (!existsSync(sessionsDir)) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const entries = await fs.readdir(sessionsDir, { withFileTypes: true });
|
|
200
|
+
const legacyFiles = entries.filter(
|
|
201
|
+
(e) => e.isFile() && e.name.endsWith('.json') && e.name !== '.last-cleanup'
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
for (const file of legacyFiles) {
|
|
205
|
+
const hash = file.name.replace('.json', '');
|
|
206
|
+
const legacyPath = path.join(sessionsDir, file.name);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const content = JSON.parse(await fs.readFile(legacyPath, 'utf-8'));
|
|
210
|
+
|
|
211
|
+
// Only migrate if it has the old session format fields
|
|
212
|
+
if (content.sessionId && content.backupPath && content.cleanupRequired !== undefined) {
|
|
213
|
+
const paths = this.projectManager.getProjectPaths(hash);
|
|
214
|
+
|
|
215
|
+
// Create new directory structure
|
|
216
|
+
await fs.mkdir(paths.pidsDir, { recursive: true });
|
|
217
|
+
|
|
218
|
+
// Write backup.json from old session data (only if cleanup was still required)
|
|
219
|
+
if (content.cleanupRequired) {
|
|
220
|
+
const backupRef = {
|
|
221
|
+
sessionId: content.sessionId,
|
|
222
|
+
backupPath: content.backupPath,
|
|
223
|
+
projectPath: content.projectPath,
|
|
224
|
+
target: content.target,
|
|
225
|
+
createdAt: content.startTime,
|
|
226
|
+
createdByPid: content.pid,
|
|
227
|
+
};
|
|
228
|
+
await fs.writeFile(paths.backupRefFile, JSON.stringify(backupRef, null, 2));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Remove legacy file
|
|
232
|
+
await fs.unlink(legacyPath);
|
|
233
|
+
debug('migrated legacy session %s', hash);
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
debug('failed to migrate legacy session %s: %O', hash, error);
|
|
237
|
+
// Move corrupt file aside instead of deleting — preserves data for diagnosis
|
|
238
|
+
try {
|
|
239
|
+
await fs.rename(legacyPath, `${legacyPath}.corrupt`);
|
|
240
|
+
} catch {
|
|
241
|
+
// If rename fails, remove it to prevent retry loops
|
|
242
|
+
try {
|
|
243
|
+
await fs.unlink(legacyPath);
|
|
244
|
+
} catch {
|
|
245
|
+
// Ignore
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
debug('legacy migration scan failed:', error);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
181
255
|
/**
|
|
182
256
|
* Check if periodic cleanup should run (at most once per 24 hours)
|
|
183
257
|
*/
|
|
@@ -205,31 +279,28 @@ export class CleanupHandler {
|
|
|
205
279
|
}
|
|
206
280
|
|
|
207
281
|
/**
|
|
208
|
-
* Manually cleanup a specific project session
|
|
282
|
+
* Manually cleanup a specific project session.
|
|
283
|
+
* Release → restore if last → finalize AFTER restore.
|
|
209
284
|
*/
|
|
210
285
|
async cleanup(projectHash: string): Promise<void> {
|
|
211
|
-
const { shouldRestore,
|
|
212
|
-
|
|
213
|
-
if (shouldRestore &&
|
|
214
|
-
|
|
215
|
-
await this.backupManager.restoreBackup(projectHash, session.sessionId);
|
|
216
|
-
await this.backupManager.cleanupOldBackups(projectHash, 3);
|
|
217
|
-
await this.gitStashManager.popSettingsChanges(session.projectPath);
|
|
218
|
-
await this.secretsManager.clearSecrets(projectHash);
|
|
286
|
+
const { shouldRestore, backupRef } = await this.sessionManager.releaseSession(projectHash);
|
|
287
|
+
|
|
288
|
+
if (shouldRestore && backupRef) {
|
|
289
|
+
await this.restoreAndFinalize(projectHash, backupRef);
|
|
219
290
|
}
|
|
220
291
|
}
|
|
221
292
|
|
|
222
293
|
/**
|
|
223
|
-
* Clean up data for projects whose paths no longer exist
|
|
224
|
-
*
|
|
294
|
+
* Clean up data for projects whose paths no longer exist.
|
|
295
|
+
* Uses isSessionActive instead of getActiveSession.
|
|
225
296
|
*/
|
|
226
297
|
private async cleanupOrphanedProjects(): Promise<void> {
|
|
227
298
|
const allHashes = await this.getAllProjectHashes();
|
|
228
299
|
|
|
229
300
|
for (const hash of allHashes) {
|
|
230
301
|
// Skip projects with active sessions
|
|
231
|
-
const
|
|
232
|
-
if (
|
|
302
|
+
const isActive = await this.sessionManager.isSessionActive(hash);
|
|
303
|
+
if (isActive) {
|
|
233
304
|
continue;
|
|
234
305
|
}
|
|
235
306
|
|
|
@@ -267,7 +338,6 @@ export class CleanupHandler {
|
|
|
267
338
|
}
|
|
268
339
|
};
|
|
269
340
|
|
|
270
|
-
// Scan both directories in parallel
|
|
271
341
|
const [backupHashes, secretHashes] = await Promise.all([
|
|
272
342
|
scanDir(path.join(flowHome, 'backups')),
|
|
273
343
|
scanDir(path.join(flowHome, 'secrets')),
|
|
@@ -302,7 +372,7 @@ export class CleanupHandler {
|
|
|
302
372
|
}
|
|
303
373
|
|
|
304
374
|
/**
|
|
305
|
-
* Remove all stored data for a project hash (backups, secrets, session
|
|
375
|
+
* Remove all stored data for a project hash (backups, secrets, session dir)
|
|
306
376
|
*/
|
|
307
377
|
private async cleanupProjectData(projectHash: string): Promise<void> {
|
|
308
378
|
const paths = this.projectManager.getProjectPaths(projectHash);
|
|
@@ -321,11 +391,11 @@ export class CleanupHandler {
|
|
|
321
391
|
debug('failed to remove secrets for %s: %O', projectHash, error);
|
|
322
392
|
}
|
|
323
393
|
|
|
324
|
-
// Remove session
|
|
394
|
+
// Remove session directory (replaces old fs.unlink(sessionFile))
|
|
325
395
|
try {
|
|
326
|
-
await fs.
|
|
396
|
+
await fs.rm(paths.sessionDir, { recursive: true, force: true });
|
|
327
397
|
} catch {
|
|
328
|
-
// Expected:
|
|
398
|
+
// Expected: directory may not exist
|
|
329
399
|
}
|
|
330
400
|
}
|
|
331
401
|
}
|
|
@@ -60,6 +60,7 @@ export class FlowExecutor {
|
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* Try to join an existing session. Returns summary if joined, null otherwise.
|
|
63
|
+
* Always re-applies settings for self-healing (fixes corrupted state from partial restores).
|
|
63
64
|
*/
|
|
64
65
|
private async tryJoinExistingSession(
|
|
65
66
|
projectPath: string,
|
|
@@ -67,31 +68,39 @@ export class FlowExecutor {
|
|
|
67
68
|
target: string,
|
|
68
69
|
verbose?: boolean
|
|
69
70
|
): Promise<{ joined: true } | null> {
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
// Check if there's an active session (backup.json exists)
|
|
72
|
+
const backupRef = await this.sessionManager.getBackupRef(projectHash);
|
|
73
|
+
if (!backupRef) {
|
|
72
74
|
return null;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const filesExist = existsSync(agentsDir) && (await fs.readdir(agentsDir)).length > 0;
|
|
78
|
-
|
|
79
|
-
if (filesExist) {
|
|
80
|
-
await this.sessionManager.startSession(
|
|
81
|
-
projectPath,
|
|
82
|
-
projectHash,
|
|
83
|
-
target,
|
|
84
|
-
existingSession.backupPath
|
|
85
|
-
);
|
|
86
|
-
this.cleanupHandler.registerCleanupHooks(projectHash);
|
|
87
|
-
return { joined: true };
|
|
77
|
+
if (verbose) {
|
|
78
|
+
console.log(chalk.dim('Joining existing session...'));
|
|
88
79
|
}
|
|
89
80
|
|
|
90
|
-
|
|
91
|
-
|
|
81
|
+
// Register our PID with the session.
|
|
82
|
+
// Verify backupRef is still valid after acquiring — handles the TOCTOU race
|
|
83
|
+
// where cleanup could delete backup.json between getBackupRef() and acquireSession().
|
|
84
|
+
const result = await this.sessionManager.acquireSession(projectHash, projectPath, target);
|
|
85
|
+
if (!result.backupRef) {
|
|
86
|
+
// Session was cleaned up between our check and acquire — undo our PID registration
|
|
87
|
+
await this.sessionManager.releaseSession(projectHash);
|
|
88
|
+
return null;
|
|
92
89
|
}
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
|
|
91
|
+
this.cleanupHandler.registerCleanupHooks(projectHash);
|
|
92
|
+
|
|
93
|
+
// Always re-apply settings for self-healing
|
|
94
|
+
const targetObj = resolveTargetOrId(target);
|
|
95
|
+
if (targetObj.applySettings) {
|
|
96
|
+
try {
|
|
97
|
+
await targetObj.applySettings(projectPath, {});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
debug('applySettings failed during join:', error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { joined: true };
|
|
95
104
|
}
|
|
96
105
|
|
|
97
106
|
/**
|
|
@@ -128,12 +137,12 @@ export class FlowExecutor {
|
|
|
128
137
|
}),
|
|
129
138
|
]);
|
|
130
139
|
|
|
131
|
-
|
|
132
|
-
|
|
140
|
+
// Acquire session with backup info — atomic first-session detection
|
|
141
|
+
const { backupRef } = await this.sessionManager.acquireSession(
|
|
133
142
|
projectHash,
|
|
143
|
+
projectPath,
|
|
134
144
|
target,
|
|
135
|
-
backup.backupPath
|
|
136
|
-
backup.sessionId
|
|
145
|
+
{ sessionId: backup.sessionId, backupPath: backup.backupPath }
|
|
137
146
|
);
|
|
138
147
|
|
|
139
148
|
this.cleanupHandler.registerCleanupHooks(projectHash);
|
|
@@ -142,8 +151,9 @@ export class FlowExecutor {
|
|
|
142
151
|
await this.clearUserSettings(projectPath, target);
|
|
143
152
|
}
|
|
144
153
|
|
|
154
|
+
const sessionId = backupRef?.sessionId ?? backup.sessionId;
|
|
145
155
|
const templates = await this.templateLoader.loadTemplates(target);
|
|
146
|
-
const manifest = await this.backupManager.getManifest(projectHash,
|
|
156
|
+
const manifest = await this.backupManager.getManifest(projectHash, sessionId);
|
|
147
157
|
|
|
148
158
|
if (!manifest) {
|
|
149
159
|
throw new Error('Backup manifest not found');
|
|
@@ -161,7 +171,7 @@ export class FlowExecutor {
|
|
|
161
171
|
}
|
|
162
172
|
}
|
|
163
173
|
|
|
164
|
-
await this.backupManager.updateManifest(projectHash,
|
|
174
|
+
await this.backupManager.updateManifest(projectHash, sessionId, manifest);
|
|
165
175
|
|
|
166
176
|
return {
|
|
167
177
|
agents: attachResult.agentsAdded.length,
|
|
@@ -18,7 +18,9 @@ import { targetManager } from './target-manager.js';
|
|
|
18
18
|
const execAsync = promisify(exec);
|
|
19
19
|
|
|
20
20
|
export interface ProjectPaths {
|
|
21
|
-
|
|
21
|
+
sessionDir: string; // ~/.sylphx-flow/sessions/<hash>/
|
|
22
|
+
backupRefFile: string; // ~/.sylphx-flow/sessions/<hash>/backup.json
|
|
23
|
+
pidsDir: string; // ~/.sylphx-flow/sessions/<hash>/pids/
|
|
22
24
|
backupsDir: string;
|
|
23
25
|
secretsDir: string;
|
|
24
26
|
latestBackup: string;
|
|
@@ -45,12 +47,14 @@ export class ProjectManager {
|
|
|
45
47
|
* Get all paths for a project
|
|
46
48
|
*/
|
|
47
49
|
getProjectPaths(projectHash: string): ProjectPaths {
|
|
48
|
-
const
|
|
50
|
+
const sessionDir = path.join(this.flowHomeDir, 'sessions', projectHash);
|
|
49
51
|
const backupsDir = path.join(this.flowHomeDir, 'backups', projectHash);
|
|
50
52
|
const secretsDir = path.join(this.flowHomeDir, 'secrets', projectHash);
|
|
51
53
|
|
|
52
54
|
return {
|
|
53
|
-
|
|
55
|
+
sessionDir,
|
|
56
|
+
backupRefFile: path.join(sessionDir, 'backup.json'),
|
|
57
|
+
pidsDir: path.join(sessionDir, 'pids'),
|
|
54
58
|
backupsDir,
|
|
55
59
|
secretsDir,
|
|
56
60
|
latestBackup: path.join(backupsDir, 'latest'),
|
|
@@ -216,19 +220,19 @@ export class ProjectManager {
|
|
|
216
220
|
/**
|
|
217
221
|
* Get all active projects
|
|
218
222
|
*/
|
|
219
|
-
async getActiveProjects(): Promise<Array<{ hash: string;
|
|
223
|
+
async getActiveProjects(): Promise<Array<{ hash: string; sessionDir: string }>> {
|
|
220
224
|
const sessionsDir = path.join(this.flowHomeDir, 'sessions');
|
|
221
225
|
|
|
222
226
|
if (!existsSync(sessionsDir)) {
|
|
223
227
|
return [];
|
|
224
228
|
}
|
|
225
229
|
|
|
226
|
-
const
|
|
227
|
-
return
|
|
228
|
-
.filter((
|
|
229
|
-
.map((
|
|
230
|
-
hash:
|
|
231
|
-
|
|
230
|
+
const entries = await fs.readdir(sessionsDir, { withFileTypes: true });
|
|
231
|
+
return entries
|
|
232
|
+
.filter((entry) => entry.isDirectory() && entry.name !== 'history')
|
|
233
|
+
.map((entry) => ({
|
|
234
|
+
hash: entry.name,
|
|
235
|
+
sessionDir: path.join(sessionsDir, entry.name),
|
|
232
236
|
}));
|
|
233
237
|
}
|
|
234
238
|
|
|
@@ -243,7 +247,8 @@ export class ProjectManager {
|
|
|
243
247
|
} | null> {
|
|
244
248
|
const paths = this.getProjectPaths(projectHash);
|
|
245
249
|
|
|
246
|
-
|
|
250
|
+
// Session is active if backup.json exists in the session directory
|
|
251
|
+
const hasActiveSession = existsSync(paths.backupRefFile);
|
|
247
252
|
|
|
248
253
|
let backupsCount = 0;
|
|
249
254
|
if (existsSync(paths.backupsDir)) {
|