ccmanager 2.8.0 → 2.9.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/dist/cli.test.js +13 -2
- package/dist/components/App.js +125 -50
- package/dist/components/App.test.js +270 -0
- package/dist/components/ConfigureShortcuts.js +82 -8
- package/dist/components/DeleteWorktree.js +39 -5
- package/dist/components/DeleteWorktree.test.d.ts +1 -0
- package/dist/components/DeleteWorktree.test.js +128 -0
- package/dist/components/LoadingSpinner.d.ts +8 -0
- package/dist/components/LoadingSpinner.js +37 -0
- package/dist/components/LoadingSpinner.test.d.ts +1 -0
- package/dist/components/LoadingSpinner.test.js +187 -0
- package/dist/components/Menu.js +64 -16
- package/dist/components/Menu.recent-projects.test.js +32 -11
- package/dist/components/Menu.test.js +136 -4
- package/dist/components/MergeWorktree.js +79 -18
- package/dist/components/MergeWorktree.test.d.ts +1 -0
- package/dist/components/MergeWorktree.test.js +227 -0
- package/dist/components/NewWorktree.js +88 -9
- package/dist/components/NewWorktree.test.d.ts +1 -0
- package/dist/components/NewWorktree.test.js +244 -0
- package/dist/components/ProjectList.js +44 -13
- package/dist/components/ProjectList.recent-projects.test.js +8 -3
- package/dist/components/ProjectList.test.js +105 -8
- package/dist/components/RemoteBranchSelector.test.js +3 -1
- package/dist/components/Session.js +11 -6
- package/dist/hooks/useGitStatus.d.ts +11 -0
- package/dist/hooks/useGitStatus.js +70 -12
- package/dist/hooks/useGitStatus.test.js +30 -23
- package/dist/services/configurationManager.d.ts +75 -0
- package/dist/services/configurationManager.effect.test.d.ts +1 -0
- package/dist/services/configurationManager.effect.test.js +407 -0
- package/dist/services/configurationManager.js +246 -0
- package/dist/services/globalSessionOrchestrator.test.js +0 -8
- package/dist/services/projectManager.d.ts +98 -2
- package/dist/services/projectManager.js +228 -59
- package/dist/services/projectManager.test.js +242 -2
- package/dist/services/sessionManager.d.ts +44 -2
- package/dist/services/sessionManager.effect.test.d.ts +1 -0
- package/dist/services/sessionManager.effect.test.js +321 -0
- package/dist/services/sessionManager.js +216 -65
- package/dist/services/sessionManager.statePersistence.test.js +18 -9
- package/dist/services/sessionManager.test.js +40 -36
- package/dist/services/shortcutManager.d.ts +2 -0
- package/dist/services/shortcutManager.js +53 -0
- package/dist/services/shortcutManager.test.d.ts +1 -0
- package/dist/services/shortcutManager.test.js +30 -0
- package/dist/services/worktreeService.d.ts +356 -26
- package/dist/services/worktreeService.js +793 -353
- package/dist/services/worktreeService.test.js +294 -313
- package/dist/types/errors.d.ts +74 -0
- package/dist/types/errors.js +31 -0
- package/dist/types/errors.test.d.ts +1 -0
- package/dist/types/errors.test.js +201 -0
- package/dist/types/index.d.ts +5 -17
- package/dist/utils/claudeDir.d.ts +58 -6
- package/dist/utils/claudeDir.js +103 -8
- package/dist/utils/claudeDir.test.d.ts +1 -0
- package/dist/utils/claudeDir.test.js +108 -0
- package/dist/utils/concurrencyLimit.d.ts +5 -0
- package/dist/utils/concurrencyLimit.js +11 -0
- package/dist/utils/concurrencyLimit.test.js +40 -1
- package/dist/utils/gitStatus.d.ts +36 -8
- package/dist/utils/gitStatus.js +170 -88
- package/dist/utils/gitStatus.test.js +12 -9
- package/dist/utils/hookExecutor.d.ts +41 -6
- package/dist/utils/hookExecutor.js +75 -32
- package/dist/utils/hookExecutor.test.js +73 -20
- package/dist/utils/terminalCapabilities.d.ts +18 -0
- package/dist/utils/terminalCapabilities.js +81 -0
- package/dist/utils/terminalCapabilities.test.d.ts +1 -0
- package/dist/utils/terminalCapabilities.test.js +104 -0
- package/dist/utils/testHelpers.d.ts +106 -0
- package/dist/utils/testHelpers.js +153 -0
- package/dist/utils/testHelpers.test.d.ts +1 -0
- package/dist/utils/testHelpers.test.js +114 -0
- package/dist/utils/worktreeConfig.d.ts +77 -2
- package/dist/utils/worktreeConfig.js +156 -16
- package/dist/utils/worktreeConfig.test.d.ts +1 -0
- package/dist/utils/worktreeConfig.test.js +39 -0
- package/package.json +4 -4
- package/dist/integration-tests/devcontainer.integration.test.js +0 -101
- /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { Effect, Either } from 'effect';
|
|
3
|
+
import { spawn } from 'node-pty';
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
5
|
+
// Mock node-pty
|
|
6
|
+
vi.mock('node-pty', () => ({
|
|
7
|
+
spawn: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
// Mock child_process
|
|
10
|
+
vi.mock('child_process', () => ({
|
|
11
|
+
exec: vi.fn(),
|
|
12
|
+
execFile: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
// Mock configuration manager
|
|
15
|
+
vi.mock('./configurationManager.js', () => ({
|
|
16
|
+
configurationManager: {
|
|
17
|
+
getDefaultPreset: vi.fn(),
|
|
18
|
+
getPresetById: vi.fn(),
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
// Mock Terminal
|
|
22
|
+
vi.mock('@xterm/headless', () => ({
|
|
23
|
+
default: {
|
|
24
|
+
Terminal: vi.fn().mockImplementation(() => ({
|
|
25
|
+
buffer: {
|
|
26
|
+
active: {
|
|
27
|
+
length: 0,
|
|
28
|
+
getLine: vi.fn(),
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
write: vi.fn(),
|
|
32
|
+
})),
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
// Create a mock IPty class
|
|
36
|
+
class MockPty extends EventEmitter {
|
|
37
|
+
constructor() {
|
|
38
|
+
super(...arguments);
|
|
39
|
+
Object.defineProperty(this, "kill", {
|
|
40
|
+
enumerable: true,
|
|
41
|
+
configurable: true,
|
|
42
|
+
writable: true,
|
|
43
|
+
value: vi.fn()
|
|
44
|
+
});
|
|
45
|
+
Object.defineProperty(this, "resize", {
|
|
46
|
+
enumerable: true,
|
|
47
|
+
configurable: true,
|
|
48
|
+
writable: true,
|
|
49
|
+
value: vi.fn()
|
|
50
|
+
});
|
|
51
|
+
Object.defineProperty(this, "write", {
|
|
52
|
+
enumerable: true,
|
|
53
|
+
configurable: true,
|
|
54
|
+
writable: true,
|
|
55
|
+
value: vi.fn()
|
|
56
|
+
});
|
|
57
|
+
Object.defineProperty(this, "onData", {
|
|
58
|
+
enumerable: true,
|
|
59
|
+
configurable: true,
|
|
60
|
+
writable: true,
|
|
61
|
+
value: vi.fn((callback) => {
|
|
62
|
+
this.on('data', callback);
|
|
63
|
+
})
|
|
64
|
+
});
|
|
65
|
+
Object.defineProperty(this, "onExit", {
|
|
66
|
+
enumerable: true,
|
|
67
|
+
configurable: true,
|
|
68
|
+
writable: true,
|
|
69
|
+
value: vi.fn((callback) => {
|
|
70
|
+
this.on('exit', callback);
|
|
71
|
+
})
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
describe('SessionManager Effect-based Operations', () => {
|
|
76
|
+
let sessionManager;
|
|
77
|
+
let mockPty;
|
|
78
|
+
let SessionManager;
|
|
79
|
+
let configurationManager;
|
|
80
|
+
beforeEach(async () => {
|
|
81
|
+
vi.clearAllMocks();
|
|
82
|
+
// Dynamically import after mocks are set up
|
|
83
|
+
const sessionManagerModule = await import('./sessionManager.js');
|
|
84
|
+
const configManagerModule = await import('./configurationManager.js');
|
|
85
|
+
SessionManager = sessionManagerModule.SessionManager;
|
|
86
|
+
configurationManager = configManagerModule.configurationManager;
|
|
87
|
+
sessionManager = new SessionManager();
|
|
88
|
+
mockPty = new MockPty();
|
|
89
|
+
});
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
sessionManager.destroy();
|
|
92
|
+
});
|
|
93
|
+
describe('createSessionWithPreset returning Effect', () => {
|
|
94
|
+
it('should return Effect that succeeds with Session', async () => {
|
|
95
|
+
// Setup mock preset
|
|
96
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
97
|
+
id: '1',
|
|
98
|
+
name: 'Main',
|
|
99
|
+
command: 'claude',
|
|
100
|
+
args: ['--preset-arg'],
|
|
101
|
+
});
|
|
102
|
+
// Setup spawn mock
|
|
103
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
104
|
+
// Create session with preset - should return Effect
|
|
105
|
+
const effect = sessionManager.createSessionWithPresetEffect('/test/worktree');
|
|
106
|
+
// Execute the Effect and verify it succeeds with a Session
|
|
107
|
+
const session = await Effect.runPromise(effect);
|
|
108
|
+
expect(session).toBeDefined();
|
|
109
|
+
expect(session.worktreePath).toBe('/test/worktree');
|
|
110
|
+
expect(session.state).toBe('busy');
|
|
111
|
+
});
|
|
112
|
+
it('should return Effect that fails with ConfigError when preset not found', async () => {
|
|
113
|
+
// Setup mocks - both return null/undefined
|
|
114
|
+
vi.mocked(configurationManager.getPresetById).mockReturnValue(undefined);
|
|
115
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue(undefined);
|
|
116
|
+
// Create session with non-existent preset - should return Effect
|
|
117
|
+
const effect = sessionManager.createSessionWithPresetEffect('/test/worktree', 'invalid-preset');
|
|
118
|
+
// Execute the Effect and expect it to fail with ConfigError
|
|
119
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
120
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
121
|
+
if (Either.isLeft(result)) {
|
|
122
|
+
expect(result.left._tag).toBe('ConfigError');
|
|
123
|
+
if (result.left._tag === 'ConfigError') {
|
|
124
|
+
expect(result.left.reason).toBe('validation');
|
|
125
|
+
expect(result.left.details).toContain('preset');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
it('should return Effect that fails with ProcessError when spawn fails', async () => {
|
|
130
|
+
// Setup mock preset
|
|
131
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
132
|
+
id: '1',
|
|
133
|
+
name: 'Main',
|
|
134
|
+
command: 'invalid-command',
|
|
135
|
+
args: ['--arg'],
|
|
136
|
+
});
|
|
137
|
+
// Mock spawn to throw error
|
|
138
|
+
vi.mocked(spawn).mockImplementation(() => {
|
|
139
|
+
throw new Error('spawn ENOENT: command not found');
|
|
140
|
+
});
|
|
141
|
+
// Create session - should return Effect
|
|
142
|
+
const effect = sessionManager.createSessionWithPresetEffect('/test/worktree');
|
|
143
|
+
// Execute the Effect and expect it to fail with ProcessError
|
|
144
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
145
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
146
|
+
if (Either.isLeft(result)) {
|
|
147
|
+
expect(result.left._tag).toBe('ProcessError');
|
|
148
|
+
if (result.left._tag === 'ProcessError') {
|
|
149
|
+
expect(result.left.command).toContain('createSessionWithPreset');
|
|
150
|
+
expect(result.left.message).toContain('spawn');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
it('should return existing session without creating new Effect', async () => {
|
|
155
|
+
// Setup mock preset
|
|
156
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
157
|
+
id: '1',
|
|
158
|
+
name: 'Main',
|
|
159
|
+
command: 'claude',
|
|
160
|
+
});
|
|
161
|
+
// Setup spawn mock
|
|
162
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
163
|
+
// Create session twice
|
|
164
|
+
const effect1 = sessionManager.createSessionWithPresetEffect('/test/worktree');
|
|
165
|
+
const session1 = await Effect.runPromise(effect1);
|
|
166
|
+
const effect2 = sessionManager.createSessionWithPresetEffect('/test/worktree');
|
|
167
|
+
const session2 = await Effect.runPromise(effect2);
|
|
168
|
+
// Should return the same session
|
|
169
|
+
expect(session1).toBe(session2);
|
|
170
|
+
// Spawn should only be called once
|
|
171
|
+
expect(spawn).toHaveBeenCalledTimes(1);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
describe('createSessionWithDevcontainer returning Effect', () => {
|
|
175
|
+
it('should return Effect that succeeds with Session', async () => {
|
|
176
|
+
// Setup mock preset
|
|
177
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
178
|
+
id: '1',
|
|
179
|
+
name: 'Main',
|
|
180
|
+
command: 'claude',
|
|
181
|
+
args: ['--resume'],
|
|
182
|
+
});
|
|
183
|
+
// Setup spawn mock
|
|
184
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
185
|
+
// Mock exec to succeed
|
|
186
|
+
const { exec } = await import('child_process');
|
|
187
|
+
const mockExec = vi.mocked(exec);
|
|
188
|
+
mockExec.mockImplementation((cmd, options, callback) => {
|
|
189
|
+
if (typeof options === 'function') {
|
|
190
|
+
callback = options;
|
|
191
|
+
}
|
|
192
|
+
if (callback && typeof callback === 'function') {
|
|
193
|
+
callback(null, 'Container started', '');
|
|
194
|
+
}
|
|
195
|
+
return {};
|
|
196
|
+
});
|
|
197
|
+
const devcontainerConfig = {
|
|
198
|
+
upCommand: 'devcontainer up --workspace-folder .',
|
|
199
|
+
execCommand: 'devcontainer exec --workspace-folder .',
|
|
200
|
+
};
|
|
201
|
+
// Create session with devcontainer - should return Effect
|
|
202
|
+
const effect = sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig);
|
|
203
|
+
// Execute the Effect and verify it succeeds with a Session
|
|
204
|
+
const session = await Effect.runPromise(effect);
|
|
205
|
+
expect(session).toBeDefined();
|
|
206
|
+
expect(session.worktreePath).toBe('/test/worktree');
|
|
207
|
+
expect(session.devcontainerConfig).toEqual(devcontainerConfig);
|
|
208
|
+
});
|
|
209
|
+
it('should return Effect that fails with ProcessError when devcontainer up fails', async () => {
|
|
210
|
+
// Mock exec to fail
|
|
211
|
+
const { exec } = await import('child_process');
|
|
212
|
+
const mockExec = vi.mocked(exec);
|
|
213
|
+
mockExec.mockImplementation((cmd, options, callback) => {
|
|
214
|
+
if (typeof options === 'function') {
|
|
215
|
+
callback = options;
|
|
216
|
+
}
|
|
217
|
+
if (callback && typeof callback === 'function') {
|
|
218
|
+
callback(new Error('Container failed to start'), '', '');
|
|
219
|
+
}
|
|
220
|
+
return {};
|
|
221
|
+
});
|
|
222
|
+
const devcontainerConfig = {
|
|
223
|
+
upCommand: 'devcontainer up --workspace-folder .',
|
|
224
|
+
execCommand: 'devcontainer exec --workspace-folder .',
|
|
225
|
+
};
|
|
226
|
+
// Create session with devcontainer - should return Effect
|
|
227
|
+
const effect = sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig);
|
|
228
|
+
// Execute the Effect and expect it to fail with ProcessError
|
|
229
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
230
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
231
|
+
if (Either.isLeft(result)) {
|
|
232
|
+
expect(result.left._tag).toBe('ProcessError');
|
|
233
|
+
if (result.left._tag === 'ProcessError') {
|
|
234
|
+
expect(result.left.command).toContain('devcontainer up');
|
|
235
|
+
expect(result.left.message).toContain('Container failed');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
it('should return Effect that fails with ConfigError when preset not found', async () => {
|
|
240
|
+
// Setup mocks - both return null/undefined
|
|
241
|
+
vi.mocked(configurationManager.getPresetById).mockReturnValue(undefined);
|
|
242
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue(undefined);
|
|
243
|
+
// Mock exec to succeed (devcontainer up)
|
|
244
|
+
const { exec } = await import('child_process');
|
|
245
|
+
const mockExec = vi.mocked(exec);
|
|
246
|
+
mockExec.mockImplementation((cmd, options, callback) => {
|
|
247
|
+
if (typeof options === 'function') {
|
|
248
|
+
callback = options;
|
|
249
|
+
}
|
|
250
|
+
if (callback && typeof callback === 'function') {
|
|
251
|
+
callback(null, 'Container started', '');
|
|
252
|
+
}
|
|
253
|
+
return {};
|
|
254
|
+
});
|
|
255
|
+
const devcontainerConfig = {
|
|
256
|
+
upCommand: 'devcontainer up',
|
|
257
|
+
execCommand: 'devcontainer exec',
|
|
258
|
+
};
|
|
259
|
+
// Create session with invalid preset
|
|
260
|
+
const effect = sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig, 'invalid-preset');
|
|
261
|
+
// Execute the Effect and expect it to fail with ConfigError
|
|
262
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
263
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
264
|
+
if (Either.isLeft(result)) {
|
|
265
|
+
expect(result.left._tag).toBe('ConfigError');
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
describe('terminateSession returning Effect', () => {
|
|
270
|
+
it('should return Effect that succeeds when session exists', async () => {
|
|
271
|
+
// Setup mock preset and create a session first
|
|
272
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
273
|
+
id: '1',
|
|
274
|
+
name: 'Main',
|
|
275
|
+
command: 'claude',
|
|
276
|
+
});
|
|
277
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
278
|
+
// Create session
|
|
279
|
+
await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
280
|
+
// Terminate session - should return Effect
|
|
281
|
+
const effect = sessionManager.terminateSessionEffect('/test/worktree');
|
|
282
|
+
// Execute the Effect and verify it succeeds
|
|
283
|
+
await Effect.runPromise(effect);
|
|
284
|
+
// Verify session was destroyed
|
|
285
|
+
expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
|
|
286
|
+
expect(mockPty.kill).toHaveBeenCalled();
|
|
287
|
+
});
|
|
288
|
+
it('should return Effect that fails with ProcessError when session does not exist', async () => {
|
|
289
|
+
// Terminate non-existent session - should return Effect
|
|
290
|
+
const effect = sessionManager.terminateSessionEffect('/nonexistent/worktree');
|
|
291
|
+
// Execute the Effect and expect it to fail with ProcessError
|
|
292
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
293
|
+
expect(Either.isLeft(result)).toBe(true);
|
|
294
|
+
if (Either.isLeft(result)) {
|
|
295
|
+
expect(result.left._tag).toBe('ProcessError');
|
|
296
|
+
expect(result.left.message).toContain('Session not found');
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
it('should return Effect that succeeds even when process kill fails', async () => {
|
|
300
|
+
// Setup mock preset and create a session
|
|
301
|
+
vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
|
|
302
|
+
id: '1',
|
|
303
|
+
name: 'Main',
|
|
304
|
+
command: 'claude',
|
|
305
|
+
});
|
|
306
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
307
|
+
// Create session
|
|
308
|
+
await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
309
|
+
// Mock kill to throw error
|
|
310
|
+
mockPty.kill.mockImplementation(() => {
|
|
311
|
+
throw new Error('Process already terminated');
|
|
312
|
+
});
|
|
313
|
+
// Terminate session - should still succeed
|
|
314
|
+
const effect = sessionManager.terminateSessionEffect('/test/worktree');
|
|
315
|
+
// Should not throw, gracefully handle kill failure
|
|
316
|
+
await Effect.runPromise(effect);
|
|
317
|
+
// Session should still be removed from map
|
|
318
|
+
expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -7,6 +7,8 @@ import { configurationManager } from './configurationManager.js';
|
|
|
7
7
|
import { executeStatusHook } from '../utils/hookExecutor.js';
|
|
8
8
|
import { createStateDetector } from './stateDetector.js';
|
|
9
9
|
import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
|
|
10
|
+
import { Effect } from 'effect';
|
|
11
|
+
import { ProcessError, ConfigError } from '../types/errors.js';
|
|
10
12
|
const { Terminal } = pkg;
|
|
11
13
|
const execAsync = promisify(exec);
|
|
12
14
|
export class SessionManager extends EventEmitter {
|
|
@@ -86,29 +88,78 @@ export class SessionManager extends EventEmitter {
|
|
|
86
88
|
this.emit('sessionCreated', session);
|
|
87
89
|
return session;
|
|
88
90
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Create session with command preset using Effect-based error handling
|
|
93
|
+
*
|
|
94
|
+
* @param {string} worktreePath - Path to the worktree
|
|
95
|
+
* @param {string} [presetId] - Optional preset ID, uses default if not provided
|
|
96
|
+
* @returns {Effect.Effect<Session, ProcessError | ConfigError, never>} Effect that may fail with ProcessError (spawn failure) or ConfigError (invalid preset)
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```typescript
|
|
100
|
+
* // Use Effect.match for type-safe error handling
|
|
101
|
+
* const result = await Effect.runPromise(
|
|
102
|
+
* Effect.match(effect, {
|
|
103
|
+
* onFailure: (error) => ({ type: 'error', message: error.message }),
|
|
104
|
+
* onSuccess: (session) => ({ type: 'success', data: session })
|
|
105
|
+
* })
|
|
106
|
+
* );
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
createSessionWithPresetEffect(worktreePath, presetId) {
|
|
110
|
+
return Effect.tryPromise({
|
|
111
|
+
try: async () => {
|
|
112
|
+
// Check if session already exists
|
|
113
|
+
const existing = this.sessions.get(worktreePath);
|
|
114
|
+
if (existing) {
|
|
115
|
+
return existing;
|
|
116
|
+
}
|
|
117
|
+
// Get preset configuration
|
|
118
|
+
let preset = presetId
|
|
119
|
+
? configurationManager.getPresetById(presetId)
|
|
120
|
+
: null;
|
|
121
|
+
if (!preset) {
|
|
122
|
+
preset = configurationManager.getDefaultPreset();
|
|
123
|
+
}
|
|
124
|
+
// Validate preset exists
|
|
125
|
+
if (!preset) {
|
|
126
|
+
throw new ConfigError({
|
|
127
|
+
configPath: 'configuration',
|
|
128
|
+
reason: 'validation',
|
|
129
|
+
details: presetId
|
|
130
|
+
? `Preset with ID '${presetId}' not found and no default preset available`
|
|
131
|
+
: 'No default preset available',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const command = preset.command;
|
|
135
|
+
const args = preset.args || [];
|
|
136
|
+
const commandConfig = {
|
|
137
|
+
command: preset.command,
|
|
138
|
+
args: preset.args,
|
|
139
|
+
fallbackArgs: preset.fallbackArgs,
|
|
140
|
+
};
|
|
141
|
+
// Spawn the process - fallback will be handled by setupExitHandler
|
|
142
|
+
const ptyProcess = await this.spawn(command, args, worktreePath);
|
|
143
|
+
return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
|
|
144
|
+
isPrimaryCommand: true,
|
|
145
|
+
detectionStrategy: preset.detectionStrategy,
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
catch: (error) => {
|
|
149
|
+
// If it's already a ConfigError, return it
|
|
150
|
+
if (error instanceof ConfigError) {
|
|
151
|
+
return error;
|
|
152
|
+
}
|
|
153
|
+
// Otherwise, wrap in ProcessError
|
|
154
|
+
return new ProcessError({
|
|
155
|
+
command: presetId
|
|
156
|
+
? `createSessionWithPreset (preset: ${presetId})`
|
|
157
|
+
: 'createSessionWithPreset (default preset)',
|
|
158
|
+
message: error instanceof Error
|
|
159
|
+
? error.message
|
|
160
|
+
: 'Failed to create session with preset',
|
|
161
|
+
});
|
|
162
|
+
},
|
|
112
163
|
});
|
|
113
164
|
}
|
|
114
165
|
setupDataHandler(session) {
|
|
@@ -213,8 +264,8 @@ export class SessionManager extends EventEmitter {
|
|
|
213
264
|
session.state = detectedState;
|
|
214
265
|
session.pendingState = undefined;
|
|
215
266
|
session.pendingStateStart = undefined;
|
|
216
|
-
// Execute status hook asynchronously (non-blocking)
|
|
217
|
-
void executeStatusHook(oldState, detectedState, session);
|
|
267
|
+
// Execute status hook asynchronously (non-blocking) using Effect
|
|
268
|
+
void Effect.runPromise(executeStatusHook(oldState, detectedState, session));
|
|
218
269
|
this.emit('sessionStateChanged', session);
|
|
219
270
|
}
|
|
220
271
|
}
|
|
@@ -280,49 +331,149 @@ export class SessionManager extends EventEmitter {
|
|
|
280
331
|
this.emit('sessionDestroyed', session);
|
|
281
332
|
}
|
|
282
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Terminate session and cleanup resources using Effect-based error handling
|
|
336
|
+
*
|
|
337
|
+
* @param {string} worktreePath - Path to the worktree
|
|
338
|
+
* @returns {Effect.Effect<void, ProcessError, never>} Effect that may fail with ProcessError if session does not exist or cleanup fails
|
|
339
|
+
*
|
|
340
|
+
* @example
|
|
341
|
+
* ```typescript
|
|
342
|
+
* // Terminate session with error handling
|
|
343
|
+
* const result = await Effect.runPromise(
|
|
344
|
+
* Effect.match(effect, {
|
|
345
|
+
* onFailure: (error) => ({ type: 'error', message: error.message }),
|
|
346
|
+
* onSuccess: () => ({ type: 'success' })
|
|
347
|
+
* })
|
|
348
|
+
* );
|
|
349
|
+
* ```
|
|
350
|
+
*/
|
|
351
|
+
terminateSessionEffect(worktreePath) {
|
|
352
|
+
return Effect.try({
|
|
353
|
+
try: () => {
|
|
354
|
+
const session = this.sessions.get(worktreePath);
|
|
355
|
+
if (!session) {
|
|
356
|
+
throw new ProcessError({
|
|
357
|
+
command: 'terminateSession',
|
|
358
|
+
message: `Session not found for worktree: ${worktreePath}`,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
// Clear the state check interval
|
|
362
|
+
if (session.stateCheckInterval) {
|
|
363
|
+
clearInterval(session.stateCheckInterval);
|
|
364
|
+
}
|
|
365
|
+
// Try to kill the process - don't fail if process is already dead
|
|
366
|
+
try {
|
|
367
|
+
session.process.kill();
|
|
368
|
+
}
|
|
369
|
+
catch (_error) {
|
|
370
|
+
// Process might already be dead, this is acceptable
|
|
371
|
+
}
|
|
372
|
+
// Clean up any pending timer
|
|
373
|
+
const timer = this.busyTimers.get(worktreePath);
|
|
374
|
+
if (timer) {
|
|
375
|
+
clearTimeout(timer);
|
|
376
|
+
this.busyTimers.delete(worktreePath);
|
|
377
|
+
}
|
|
378
|
+
// Remove from sessions map and cleanup
|
|
379
|
+
this.sessions.delete(worktreePath);
|
|
380
|
+
this.waitingWithBottomBorder.delete(session.id);
|
|
381
|
+
this.emit('sessionDestroyed', session);
|
|
382
|
+
},
|
|
383
|
+
catch: (error) => {
|
|
384
|
+
// If it's already a ProcessError, return it
|
|
385
|
+
if (error instanceof ProcessError) {
|
|
386
|
+
return error;
|
|
387
|
+
}
|
|
388
|
+
// Otherwise, wrap in ProcessError
|
|
389
|
+
return new ProcessError({
|
|
390
|
+
command: 'terminateSession',
|
|
391
|
+
message: error instanceof Error
|
|
392
|
+
? error.message
|
|
393
|
+
: `Failed to terminate session for ${worktreePath}`,
|
|
394
|
+
});
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
}
|
|
283
398
|
getAllSessions() {
|
|
284
399
|
return Array.from(this.sessions.values());
|
|
285
400
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
401
|
+
/**
|
|
402
|
+
* Create session with devcontainer integration using Effect-based error handling
|
|
403
|
+
* @returns Effect that may fail with ProcessError (container/spawn failure) or ConfigError (invalid preset)
|
|
404
|
+
*/
|
|
405
|
+
createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId) {
|
|
406
|
+
return Effect.tryPromise({
|
|
407
|
+
try: async () => {
|
|
408
|
+
// Check if session already exists
|
|
409
|
+
const existing = this.sessions.get(worktreePath);
|
|
410
|
+
if (existing) {
|
|
411
|
+
return existing;
|
|
412
|
+
}
|
|
413
|
+
// Execute devcontainer up command first
|
|
414
|
+
try {
|
|
415
|
+
await execAsync(devcontainerConfig.upCommand, { cwd: worktreePath });
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
throw new ProcessError({
|
|
419
|
+
command: devcontainerConfig.upCommand,
|
|
420
|
+
message: `Failed to start devcontainer: ${error instanceof Error ? error.message : String(error)}`,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
// Get preset configuration
|
|
424
|
+
let preset = presetId
|
|
425
|
+
? configurationManager.getPresetById(presetId)
|
|
426
|
+
: null;
|
|
427
|
+
if (!preset) {
|
|
428
|
+
preset = configurationManager.getDefaultPreset();
|
|
429
|
+
}
|
|
430
|
+
// Validate preset exists
|
|
431
|
+
if (!preset) {
|
|
432
|
+
throw new ConfigError({
|
|
433
|
+
configPath: 'configuration',
|
|
434
|
+
reason: 'validation',
|
|
435
|
+
details: presetId
|
|
436
|
+
? `Preset with ID '${presetId}' not found and no default preset available`
|
|
437
|
+
: 'No default preset available',
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
// Parse the exec command to extract arguments
|
|
441
|
+
const execParts = devcontainerConfig.execCommand.split(/\s+/);
|
|
442
|
+
const devcontainerCmd = execParts[0] || 'devcontainer';
|
|
443
|
+
const execArgs = execParts.slice(1);
|
|
444
|
+
// Build the full command: devcontainer exec [args] -- [preset command] [preset args]
|
|
445
|
+
const fullArgs = [
|
|
446
|
+
...execArgs,
|
|
447
|
+
'--',
|
|
448
|
+
preset.command,
|
|
449
|
+
...(preset.args || []),
|
|
450
|
+
];
|
|
451
|
+
// Spawn the process within devcontainer
|
|
452
|
+
const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
|
|
453
|
+
const commandConfig = {
|
|
454
|
+
command: preset.command,
|
|
455
|
+
args: preset.args,
|
|
456
|
+
fallbackArgs: preset.fallbackArgs,
|
|
457
|
+
};
|
|
458
|
+
return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
|
|
459
|
+
isPrimaryCommand: true,
|
|
460
|
+
detectionStrategy: preset.detectionStrategy,
|
|
461
|
+
devcontainerConfig,
|
|
462
|
+
});
|
|
463
|
+
},
|
|
464
|
+
catch: (error) => {
|
|
465
|
+
// If it's already a ConfigError or ProcessError, return it
|
|
466
|
+
if (error instanceof ConfigError || error instanceof ProcessError) {
|
|
467
|
+
return error;
|
|
468
|
+
}
|
|
469
|
+
// Otherwise, wrap in ProcessError
|
|
470
|
+
return new ProcessError({
|
|
471
|
+
command: `createSessionWithDevcontainer (${devcontainerConfig.execCommand})`,
|
|
472
|
+
message: error instanceof Error
|
|
473
|
+
? error.message
|
|
474
|
+
: 'Failed to create session with devcontainer',
|
|
475
|
+
});
|
|
476
|
+
},
|
|
326
477
|
});
|
|
327
478
|
}
|
|
328
479
|
destroy() {
|