ccmanager 4.3.2 → 4.4.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/README.md +42 -0
- package/dist/components/App.js +46 -21
- package/dist/components/App.test.js +86 -0
- package/dist/components/Dashboard.js +10 -3
- package/dist/components/Menu.js +6 -1
- package/dist/components/RestoreSessions.d.ts +19 -0
- package/dist/components/RestoreSessions.js +28 -0
- package/dist/hooks/useAvailableLabelWidth.d.ts +6 -0
- package/dist/hooks/useAvailableLabelWidth.js +15 -0
- package/dist/services/config/globalConfigManager.js +3 -12
- package/dist/services/globalSessionOrchestrator.d.ts +7 -0
- package/dist/services/globalSessionOrchestrator.js +40 -0
- package/dist/services/globalSessionOrchestrator.restore.test.d.ts +1 -0
- package/dist/services/globalSessionOrchestrator.restore.test.js +76 -0
- package/dist/services/projectManager.js +3 -11
- package/dist/services/sessionManager.d.ts +6 -0
- package/dist/services/sessionManager.js +16 -0
- package/dist/services/sessionRestoreStore.d.ts +75 -0
- package/dist/services/sessionRestoreStore.js +138 -0
- package/dist/services/sessionRestoreStore.test.d.ts +1 -0
- package/dist/services/sessionRestoreStore.test.js +92 -0
- package/dist/services/sessionRestorer.d.ts +46 -0
- package/dist/services/sessionRestorer.js +123 -0
- package/dist/services/sessionRestorer.test.d.ts +1 -0
- package/dist/services/sessionRestorer.test.js +163 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/utils/configDir.d.ts +10 -0
- package/dist/utils/configDir.js +31 -0
- package/dist/utils/errorMessage.d.ts +7 -0
- package/dist/utils/errorMessage.js +19 -0
- package/dist/utils/filterByQuery.test.js +2 -0
- package/dist/utils/gitUtils.d.ts +1 -0
- package/dist/utils/gitUtils.js +15 -0
- package/dist/utils/hookExecutor.test.js +4 -0
- package/dist/utils/worktreeUtils.d.ts +25 -4
- package/dist/utils/worktreeUtils.js +53 -17
- package/dist/utils/worktreeUtils.test.js +62 -4
- package/package.json +6 -6
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
const recordMock = vi.fn();
|
|
4
|
+
const forgetMock = vi.fn();
|
|
5
|
+
const renameMock = vi.fn();
|
|
6
|
+
const suspendTrackingMock = vi.fn();
|
|
7
|
+
vi.mock('./sessionRestoreStore.js', () => ({
|
|
8
|
+
sessionRestoreStore: {
|
|
9
|
+
record: (...args) => recordMock(...args),
|
|
10
|
+
forget: (...args) => forgetMock(...args),
|
|
11
|
+
rename: (...args) => renameMock(...args),
|
|
12
|
+
suspendTracking: () => suspendTrackingMock(),
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('../utils/gitUtils.js', () => ({
|
|
16
|
+
getCurrentRepositoryRoot: () => '/current/repo',
|
|
17
|
+
}));
|
|
18
|
+
class MockSessionManager extends EventEmitter {
|
|
19
|
+
getAllSessions() {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
destroy() { }
|
|
23
|
+
}
|
|
24
|
+
vi.mock('./sessionManager.js', () => ({
|
|
25
|
+
SessionManager: MockSessionManager,
|
|
26
|
+
}));
|
|
27
|
+
const { globalSessionOrchestrator } = await import('./globalSessionOrchestrator.js');
|
|
28
|
+
const session = (overrides = {}) => ({
|
|
29
|
+
id: 'session-1',
|
|
30
|
+
worktreePath: '/repo/worktrees/feature',
|
|
31
|
+
presetId: 'preset-1',
|
|
32
|
+
sessionName: undefined,
|
|
33
|
+
devcontainerConfig: undefined,
|
|
34
|
+
...overrides,
|
|
35
|
+
});
|
|
36
|
+
describe('GlobalSessionOrchestrator session record', () => {
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
recordMock.mockClear();
|
|
39
|
+
forgetMock.mockClear();
|
|
40
|
+
renameMock.mockClear();
|
|
41
|
+
suspendTrackingMock.mockClear();
|
|
42
|
+
});
|
|
43
|
+
it('records a session of the current repository when there is no project path', () => {
|
|
44
|
+
const manager = globalSessionOrchestrator.getManagerForProject();
|
|
45
|
+
manager.emit('sessionCreated', session());
|
|
46
|
+
expect(recordMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
47
|
+
id: 'session-1',
|
|
48
|
+
projectPath: '/current/repo',
|
|
49
|
+
worktreePath: '/repo/worktrees/feature',
|
|
50
|
+
presetId: 'preset-1',
|
|
51
|
+
ownerPid: process.pid,
|
|
52
|
+
}));
|
|
53
|
+
});
|
|
54
|
+
it('records a session under the project its manager belongs to', () => {
|
|
55
|
+
const manager = globalSessionOrchestrator.getManagerForProject('/other/project');
|
|
56
|
+
manager.emit('sessionCreated', session({ id: 'session-2' }));
|
|
57
|
+
expect(recordMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
58
|
+
id: 'session-2',
|
|
59
|
+
projectPath: '/other/project',
|
|
60
|
+
}));
|
|
61
|
+
});
|
|
62
|
+
it('forgets a session that was killed or exited', () => {
|
|
63
|
+
const manager = globalSessionOrchestrator.getManagerForProject();
|
|
64
|
+
manager.emit('sessionDestroyed', session());
|
|
65
|
+
expect(forgetMock).toHaveBeenCalledWith('session-1');
|
|
66
|
+
});
|
|
67
|
+
it('keeps the record in step with a rename', () => {
|
|
68
|
+
const manager = globalSessionOrchestrator.getManagerForProject();
|
|
69
|
+
manager.emit('sessionRenamed', session({ sessionName: 'review' }));
|
|
70
|
+
expect(renameMock).toHaveBeenCalledWith('session-1', 'review');
|
|
71
|
+
});
|
|
72
|
+
it('stops tracking before quitting, so the sessions stay restorable', () => {
|
|
73
|
+
globalSessionOrchestrator.destroyAllSessions();
|
|
74
|
+
expect(suspendTrackingMock).toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -2,10 +2,10 @@ import { WorktreeService } from './worktreeService.js';
|
|
|
2
2
|
import { ENV_VARS } from '../constants/env.js';
|
|
3
3
|
import { promises as fs } from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
import {
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
7
6
|
import { Effect } from 'effect';
|
|
8
7
|
import { FileSystemError, ConfigError } from '../types/errors.js';
|
|
8
|
+
import { ensureConfigDir } from '../utils/configDir.js';
|
|
9
9
|
export class ProjectManager {
|
|
10
10
|
currentMode;
|
|
11
11
|
currentProject;
|
|
@@ -31,15 +31,7 @@ export class ProjectManager {
|
|
|
31
31
|
this.currentMode = 'normal';
|
|
32
32
|
}
|
|
33
33
|
// Initialize recent projects
|
|
34
|
-
|
|
35
|
-
this.configDir =
|
|
36
|
-
process.platform === 'win32'
|
|
37
|
-
? path.join(process.env['APPDATA'] || path.join(homeDir, 'AppData', 'Roaming'), 'ccmanager')
|
|
38
|
-
: path.join(homeDir, '.config', 'ccmanager');
|
|
39
|
-
// Ensure config directory exists
|
|
40
|
-
if (!existsSync(this.configDir)) {
|
|
41
|
-
mkdirSync(this.configDir, { recursive: true });
|
|
42
|
-
}
|
|
34
|
+
this.configDir = ensureConfigDir();
|
|
43
35
|
this.dataPath = path.join(this.configDir, 'recent-projects.json');
|
|
44
36
|
this.loadRecentProjects();
|
|
45
37
|
}
|
|
@@ -83,6 +83,12 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
83
83
|
private setupBackgroundHandler;
|
|
84
84
|
private cleanupSession;
|
|
85
85
|
getSessionById(id: string): Session | undefined;
|
|
86
|
+
/**
|
|
87
|
+
* Assign a user-facing name to a session. Goes through the manager rather
|
|
88
|
+
* than mutating the session object directly so that listeners (currently the
|
|
89
|
+
* restore record) learn about the new name.
|
|
90
|
+
*/
|
|
91
|
+
renameSession(sessionId: string, sessionName?: string): void;
|
|
86
92
|
getSessionsForWorktree(worktreePath: string): Session[];
|
|
87
93
|
setSessionActive(sessionId: string, active: boolean): void;
|
|
88
94
|
private emitRestoreSnapshot;
|
|
@@ -342,6 +342,7 @@ export class SessionManager extends EventEmitter {
|
|
|
342
342
|
stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
|
|
343
343
|
isPrimaryCommand: options.isPrimaryCommand ?? true,
|
|
344
344
|
presetName: options.presetName,
|
|
345
|
+
presetId: options.presetId,
|
|
345
346
|
detectionStrategy,
|
|
346
347
|
devcontainerConfig: options.devcontainerConfig ?? undefined,
|
|
347
348
|
stateMutex: new Mutex(createInitialSessionStateData()),
|
|
@@ -385,6 +386,7 @@ export class SessionManager extends EventEmitter {
|
|
|
385
386
|
command,
|
|
386
387
|
fallbackArgs: preset.fallbackArgs,
|
|
387
388
|
presetName: preset.name,
|
|
389
|
+
presetId: preset.id,
|
|
388
390
|
detectionStrategy: preset.detectionStrategy,
|
|
389
391
|
});
|
|
390
392
|
if (launch.stdinPayload) {
|
|
@@ -569,6 +571,19 @@ export class SessionManager extends EventEmitter {
|
|
|
569
571
|
getSessionById(id) {
|
|
570
572
|
return this.sessions.get(id);
|
|
571
573
|
}
|
|
574
|
+
/**
|
|
575
|
+
* Assign a user-facing name to a session. Goes through the manager rather
|
|
576
|
+
* than mutating the session object directly so that listeners (currently the
|
|
577
|
+
* restore record) learn about the new name.
|
|
578
|
+
*/
|
|
579
|
+
renameSession(sessionId, sessionName) {
|
|
580
|
+
const session = this.sessions.get(sessionId);
|
|
581
|
+
if (!session) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
session.sessionName = sessionName;
|
|
585
|
+
this.emit('sessionRenamed', session);
|
|
586
|
+
}
|
|
572
587
|
getSessionsForWorktree(worktreePath) {
|
|
573
588
|
return Array.from(this.sessions.values()).filter(s => s.worktreePath === worktreePath);
|
|
574
589
|
}
|
|
@@ -878,6 +893,7 @@ export class SessionManager extends EventEmitter {
|
|
|
878
893
|
command: preset.command,
|
|
879
894
|
fallbackArgs: preset.fallbackArgs,
|
|
880
895
|
presetName: preset.name,
|
|
896
|
+
presetId: preset.id,
|
|
881
897
|
detectionStrategy: preset.detectionStrategy,
|
|
882
898
|
devcontainerConfig,
|
|
883
899
|
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { DevcontainerConfig } from '../types/index.js';
|
|
2
|
+
/** Format version of the on-disk file, so future changes can be detected. */
|
|
3
|
+
export declare const SESSION_RECORD_VERSION = 1;
|
|
4
|
+
export declare const SESSION_RECORD_FILE_NAME = "sessions.json";
|
|
5
|
+
/** A single session, described well enough to launch it again. */
|
|
6
|
+
export interface SessionRecord {
|
|
7
|
+
/** Id of the in-memory session this record was written for. */
|
|
8
|
+
id: string;
|
|
9
|
+
/**
|
|
10
|
+
* Git repository root the session belongs to. Used to only offer sessions
|
|
11
|
+
* of the repository the user is actually opening.
|
|
12
|
+
*/
|
|
13
|
+
projectPath: string;
|
|
14
|
+
worktreePath: string;
|
|
15
|
+
/**
|
|
16
|
+
* Id of the command preset the session was launched with. Absent when the
|
|
17
|
+
* preset could not be determined; the default preset is then used on
|
|
18
|
+
* restore. Only the id is stored — the command itself stays owned by the
|
|
19
|
+
* preset configuration.
|
|
20
|
+
*/
|
|
21
|
+
presetId?: string;
|
|
22
|
+
/** User-assigned session name, if the user renamed the session. */
|
|
23
|
+
sessionName?: string;
|
|
24
|
+
/** Devcontainer commands the session was launched through, if any. */
|
|
25
|
+
devcontainerConfig?: DevcontainerConfig;
|
|
26
|
+
/**
|
|
27
|
+
* Process id of the ccmanager run that owns this session. A record whose
|
|
28
|
+
* owner process is still running belongs to another live ccmanager and must
|
|
29
|
+
* not be restored, or the same session would end up running twice.
|
|
30
|
+
*/
|
|
31
|
+
ownerPid: number;
|
|
32
|
+
createdAt: number;
|
|
33
|
+
}
|
|
34
|
+
export declare class SessionRestoreStore {
|
|
35
|
+
private explicitFilePath?;
|
|
36
|
+
/**
|
|
37
|
+
* While true, mutations are ignored. Set just before ccmanager tears its
|
|
38
|
+
* sessions down on exit: those sessions are exactly the ones the next run
|
|
39
|
+
* should offer to restore, so their records must survive the teardown.
|
|
40
|
+
*/
|
|
41
|
+
private trackingSuspended;
|
|
42
|
+
constructor(filePath?: string);
|
|
43
|
+
/**
|
|
44
|
+
* Resolved lazily rather than in the constructor so that merely importing
|
|
45
|
+
* this module does not create the configuration directory.
|
|
46
|
+
*/
|
|
47
|
+
private get filePath();
|
|
48
|
+
/** All recorded sessions, including ones owned by other ccmanager runs. */
|
|
49
|
+
list(): SessionRecord[];
|
|
50
|
+
/** Add a session to the record, replacing any earlier record with the same id. */
|
|
51
|
+
record(session: SessionRecord): void;
|
|
52
|
+
/**
|
|
53
|
+
* Drop sessions from the record, so they are not offered on the next run.
|
|
54
|
+
* Called when a session ends for a reason that means it should stay ended:
|
|
55
|
+
* the user killed it, or the launched command exited by itself.
|
|
56
|
+
*/
|
|
57
|
+
forget(...ids: string[]): void;
|
|
58
|
+
/**
|
|
59
|
+
* Keep a renamed session's name in the record. An absent name means the
|
|
60
|
+
* user cleared it.
|
|
61
|
+
*/
|
|
62
|
+
rename(id: string, sessionName?: string): void;
|
|
63
|
+
/**
|
|
64
|
+
* Stop applying further mutations. Used when ccmanager is shutting down and
|
|
65
|
+
* destroys its sessions: without this the teardown would erase precisely the
|
|
66
|
+
* records the next run needs.
|
|
67
|
+
*/
|
|
68
|
+
suspendTracking(): void;
|
|
69
|
+
/** Resume applying mutations. Counterpart of {@link suspendTracking}. */
|
|
70
|
+
resumeTracking(): void;
|
|
71
|
+
isTrackingSuspended(): boolean;
|
|
72
|
+
private read;
|
|
73
|
+
private write;
|
|
74
|
+
}
|
|
75
|
+
export declare const sessionRestoreStore: SessionRestoreStore;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Durable record of the sessions ccmanager currently has open,
|
|
3
|
+
* so that a later ccmanager run can offer to start them again.
|
|
4
|
+
*
|
|
5
|
+
* Only what is needed to launch the same command again is stored (which
|
|
6
|
+
* worktree, which command preset, the user-assigned session name). The
|
|
7
|
+
* terminal output and the conversation held inside the launched CLI are
|
|
8
|
+
* deliberately not stored: restoring means "run the launch command in that
|
|
9
|
+
* worktree again", not "bring back what was on screen".
|
|
10
|
+
*
|
|
11
|
+
* Every mutation is written to disk immediately and synchronously rather than
|
|
12
|
+
* on shutdown, so a crash, a `kill -9`, or a closed terminal still leaves an
|
|
13
|
+
* accurate record behind.
|
|
14
|
+
*
|
|
15
|
+
* Each mutation also re-reads the file before rewriting it. Two ccmanager
|
|
16
|
+
* processes share this one file, and a read-modify-write keeps one process's
|
|
17
|
+
* write from discarding sessions the other process recorded in the meantime.
|
|
18
|
+
*/
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
21
|
+
import { ensureConfigDir } from '../utils/configDir.js';
|
|
22
|
+
import { logger } from '../utils/logger.js';
|
|
23
|
+
/** Format version of the on-disk file, so future changes can be detected. */
|
|
24
|
+
export const SESSION_RECORD_VERSION = 1;
|
|
25
|
+
export const SESSION_RECORD_FILE_NAME = 'sessions.json';
|
|
26
|
+
export class SessionRestoreStore {
|
|
27
|
+
explicitFilePath;
|
|
28
|
+
/**
|
|
29
|
+
* While true, mutations are ignored. Set just before ccmanager tears its
|
|
30
|
+
* sessions down on exit: those sessions are exactly the ones the next run
|
|
31
|
+
* should offer to restore, so their records must survive the teardown.
|
|
32
|
+
*/
|
|
33
|
+
trackingSuspended = false;
|
|
34
|
+
constructor(filePath) {
|
|
35
|
+
this.explicitFilePath = filePath;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolved lazily rather than in the constructor so that merely importing
|
|
39
|
+
* this module does not create the configuration directory.
|
|
40
|
+
*/
|
|
41
|
+
get filePath() {
|
|
42
|
+
return (this.explicitFilePath ??
|
|
43
|
+
path.join(ensureConfigDir(), SESSION_RECORD_FILE_NAME));
|
|
44
|
+
}
|
|
45
|
+
/** All recorded sessions, including ones owned by other ccmanager runs. */
|
|
46
|
+
list() {
|
|
47
|
+
return this.read();
|
|
48
|
+
}
|
|
49
|
+
/** Add a session to the record, replacing any earlier record with the same id. */
|
|
50
|
+
record(session) {
|
|
51
|
+
if (this.trackingSuspended) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this.write([...this.read().filter(s => s.id !== session.id), session]);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Drop sessions from the record, so they are not offered on the next run.
|
|
58
|
+
* Called when a session ends for a reason that means it should stay ended:
|
|
59
|
+
* the user killed it, or the launched command exited by itself.
|
|
60
|
+
*/
|
|
61
|
+
forget(...ids) {
|
|
62
|
+
if (this.trackingSuspended || ids.length === 0) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const dropped = new Set(ids);
|
|
66
|
+
const remaining = this.read().filter(session => !dropped.has(session.id));
|
|
67
|
+
this.write(remaining);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Keep a renamed session's name in the record. An absent name means the
|
|
71
|
+
* user cleared it.
|
|
72
|
+
*/
|
|
73
|
+
rename(id, sessionName) {
|
|
74
|
+
if (this.trackingSuspended) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const sessions = this.read();
|
|
78
|
+
const target = sessions.find(session => session.id === id);
|
|
79
|
+
if (!target) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
target.sessionName = sessionName;
|
|
83
|
+
this.write(sessions);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Stop applying further mutations. Used when ccmanager is shutting down and
|
|
87
|
+
* destroys its sessions: without this the teardown would erase precisely the
|
|
88
|
+
* records the next run needs.
|
|
89
|
+
*/
|
|
90
|
+
suspendTracking() {
|
|
91
|
+
this.trackingSuspended = true;
|
|
92
|
+
}
|
|
93
|
+
/** Resume applying mutations. Counterpart of {@link suspendTracking}. */
|
|
94
|
+
resumeTracking() {
|
|
95
|
+
this.trackingSuspended = false;
|
|
96
|
+
}
|
|
97
|
+
isTrackingSuspended() {
|
|
98
|
+
return this.trackingSuspended;
|
|
99
|
+
}
|
|
100
|
+
read() {
|
|
101
|
+
if (!existsSync(this.filePath)) {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(readFileSync(this.filePath, 'utf-8'));
|
|
106
|
+
if (!Array.isArray(parsed.sessions)) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
return parsed.sessions.filter(session => typeof session?.id === 'string' &&
|
|
110
|
+
typeof session?.projectPath === 'string' &&
|
|
111
|
+
typeof session?.worktreePath === 'string');
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
// A truncated or hand-edited file must not stop ccmanager from
|
|
115
|
+
// starting; the worst outcome of ignoring it is no restore offer.
|
|
116
|
+
logger.warn(`Failed to read session records from ${this.filePath}: ${String(error)}`);
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
write(sessions) {
|
|
121
|
+
const contents = {
|
|
122
|
+
version: SESSION_RECORD_VERSION,
|
|
123
|
+
sessions,
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
// Write to a sibling file and rename over the target: a crash midway
|
|
127
|
+
// through then leaves the previous complete file rather than a
|
|
128
|
+
// half-written one.
|
|
129
|
+
const tempPath = `${this.filePath}.${process.pid}.tmp`;
|
|
130
|
+
writeFileSync(tempPath, JSON.stringify(contents, null, 2));
|
|
131
|
+
renameSync(tempPath, this.filePath);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
logger.warn(`Failed to write session records to ${this.filePath}: ${String(error)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export const sessionRestoreStore = new SessionRestoreStore();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { SessionRestoreStore, SESSION_RECORD_VERSION, } from './sessionRestoreStore.js';
|
|
6
|
+
describe('SessionRestoreStore', () => {
|
|
7
|
+
let directory;
|
|
8
|
+
let filePath;
|
|
9
|
+
let store;
|
|
10
|
+
const record = (overrides = {}) => ({
|
|
11
|
+
id: 'session-1',
|
|
12
|
+
projectPath: '/repo',
|
|
13
|
+
worktreePath: '/repo/worktrees/feature',
|
|
14
|
+
presetId: 'preset-1',
|
|
15
|
+
ownerPid: 1234,
|
|
16
|
+
createdAt: 1,
|
|
17
|
+
...overrides,
|
|
18
|
+
});
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
directory = mkdtempSync(path.join(tmpdir(), 'ccmanager-records-'));
|
|
21
|
+
filePath = path.join(directory, 'sessions.json');
|
|
22
|
+
store = new SessionRestoreStore(filePath);
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
rmSync(directory, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
it('returns no sessions when nothing has been recorded yet', () => {
|
|
28
|
+
expect(store.list()).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
it('writes each recorded session to disk immediately', () => {
|
|
31
|
+
store.record(record());
|
|
32
|
+
const contents = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
33
|
+
expect(contents.version).toBe(SESSION_RECORD_VERSION);
|
|
34
|
+
expect(contents.sessions).toHaveLength(1);
|
|
35
|
+
expect(contents.sessions[0].worktreePath).toBe('/repo/worktrees/feature');
|
|
36
|
+
});
|
|
37
|
+
it('replaces an existing record with the same id instead of duplicating it', () => {
|
|
38
|
+
store.record(record());
|
|
39
|
+
store.record(record({ presetId: 'preset-2' }));
|
|
40
|
+
expect(store.list()).toHaveLength(1);
|
|
41
|
+
expect(store.list()[0]?.presetId).toBe('preset-2');
|
|
42
|
+
});
|
|
43
|
+
it('forgets the requested sessions and keeps the others', () => {
|
|
44
|
+
store.record(record({ id: 'session-1' }));
|
|
45
|
+
store.record(record({ id: 'session-2' }));
|
|
46
|
+
store.record(record({ id: 'session-3' }));
|
|
47
|
+
store.forget('session-1', 'session-3');
|
|
48
|
+
expect(store.list().map(session => session.id)).toEqual(['session-2']);
|
|
49
|
+
});
|
|
50
|
+
it('keeps a renamed session name, and clears it when the name is removed', () => {
|
|
51
|
+
store.record(record());
|
|
52
|
+
store.rename('session-1', 'review');
|
|
53
|
+
expect(store.list()[0]?.sessionName).toBe('review');
|
|
54
|
+
store.rename('session-1', undefined);
|
|
55
|
+
expect(store.list()[0]?.sessionName).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
it('keeps records while tracking is suspended, so a shutdown does not erase them', () => {
|
|
58
|
+
store.record(record());
|
|
59
|
+
store.suspendTracking();
|
|
60
|
+
store.forget('session-1');
|
|
61
|
+
store.record(record({ id: 'session-2' }));
|
|
62
|
+
expect(store.list().map(session => session.id)).toEqual(['session-1']);
|
|
63
|
+
store.resumeTracking();
|
|
64
|
+
store.forget('session-1');
|
|
65
|
+
expect(store.list()).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
it('picks up records another process wrote instead of overwriting them', () => {
|
|
68
|
+
// Stands in for a second ccmanager writing to the shared file between
|
|
69
|
+
// this store's own writes.
|
|
70
|
+
store.record(record({ id: 'session-1' }));
|
|
71
|
+
const other = new SessionRestoreStore(filePath);
|
|
72
|
+
other.record(record({ id: 'other-session' }));
|
|
73
|
+
store.record(record({ id: 'session-2' }));
|
|
74
|
+
expect(store
|
|
75
|
+
.list()
|
|
76
|
+
.map(session => session.id)
|
|
77
|
+
.sort()).toEqual(['other-session', 'session-1', 'session-2']);
|
|
78
|
+
});
|
|
79
|
+
it('ignores a corrupted file rather than failing', () => {
|
|
80
|
+
writeFileSync(filePath, '{not json');
|
|
81
|
+
expect(store.list()).toEqual([]);
|
|
82
|
+
store.record(record());
|
|
83
|
+
expect(store.list()).toHaveLength(1);
|
|
84
|
+
});
|
|
85
|
+
it('ignores entries that are missing the fields needed to launch them', () => {
|
|
86
|
+
writeFileSync(filePath, JSON.stringify({
|
|
87
|
+
version: SESSION_RECORD_VERSION,
|
|
88
|
+
sessions: [record(), { id: 'broken' }],
|
|
89
|
+
}));
|
|
90
|
+
expect(store.list().map(session => session.id)).toEqual(['session-1']);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { SessionRecord } from './sessionRestoreStore.js';
|
|
2
|
+
export interface RestoreFailure {
|
|
3
|
+
record: SessionRecord;
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
export interface RestoreOutcome {
|
|
7
|
+
restored: number;
|
|
8
|
+
failures: RestoreFailure[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Recorded sessions that this ccmanager run may offer to launch again.
|
|
12
|
+
*
|
|
13
|
+
* @param options.projectPath - When given, only sessions of that repository are
|
|
14
|
+
* returned (single-project mode). Omit it to consider every recorded project,
|
|
15
|
+
* which is what multi-project mode does.
|
|
16
|
+
*/
|
|
17
|
+
export declare function listRestorableSessions(options?: {
|
|
18
|
+
projectPath?: string;
|
|
19
|
+
}): SessionRecord[];
|
|
20
|
+
/**
|
|
21
|
+
* Name of the command preset a record will be launched with, for display.
|
|
22
|
+
* Resolved from the current configuration rather than stored alongside the
|
|
23
|
+
* record, so a renamed preset shows its current name. Records whose preset no
|
|
24
|
+
* longer exists fall back to the default preset, as launching does.
|
|
25
|
+
*/
|
|
26
|
+
export declare function describeRecordPreset(record: SessionRecord): string;
|
|
27
|
+
/**
|
|
28
|
+
* Launch each recorded session again, in the session manager the running
|
|
29
|
+
* ccmanager will look at for its project.
|
|
30
|
+
*
|
|
31
|
+
* Sessions are started one at a time: each one is numbered relative to the
|
|
32
|
+
* sessions already present in its worktree, which only holds if they are not
|
|
33
|
+
* created concurrently.
|
|
34
|
+
*
|
|
35
|
+
* Every record is dropped from the durable store as it is processed — a
|
|
36
|
+
* successfully restored session records itself anew under its new id, and a
|
|
37
|
+
* failed one must not keep being offered on every subsequent start.
|
|
38
|
+
*/
|
|
39
|
+
export declare function restoreSessions(records: SessionRecord[], options: {
|
|
40
|
+
multiProject: boolean;
|
|
41
|
+
}): Promise<RestoreOutcome>;
|
|
42
|
+
/**
|
|
43
|
+
* Forget the offered sessions without launching them, so declining the offer
|
|
44
|
+
* does not make it come back on the next start.
|
|
45
|
+
*/
|
|
46
|
+
export declare function discardRestorableSessions(records: SessionRecord[]): void;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Turns the durable session record written by
|
|
3
|
+
* {@link SessionRestoreStore} back into running sessions.
|
|
4
|
+
*
|
|
5
|
+
* Restoring a session means launching its command preset in its worktree
|
|
6
|
+
* again — nothing of the previous run's terminal output or conversation is
|
|
7
|
+
* brought back. An initial prompt, if the session was originally started with
|
|
8
|
+
* one, is deliberately not replayed: it was a one-off instruction, not part of
|
|
9
|
+
* the session's identity.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync } from 'fs';
|
|
12
|
+
import { Effect, Either } from 'effect';
|
|
13
|
+
import { globalSessionOrchestrator } from './globalSessionOrchestrator.js';
|
|
14
|
+
import { sessionRestoreStore } from './sessionRestoreStore.js';
|
|
15
|
+
import { configReader } from './config/configReader.js';
|
|
16
|
+
import { formatErrorMessage } from '../utils/errorMessage.js';
|
|
17
|
+
import { logger } from '../utils/logger.js';
|
|
18
|
+
/**
|
|
19
|
+
* Whether a process with this id is currently running. Used to leave alone the
|
|
20
|
+
* sessions of another ccmanager that is still open.
|
|
21
|
+
*
|
|
22
|
+
* A process id can be reused after a reboot, in which case an unrelated live
|
|
23
|
+
* process makes a record look owned and its session is silently not offered.
|
|
24
|
+
* That is the cheaper mistake: the opposite error would start a second copy of
|
|
25
|
+
* a session that is already running in another ccmanager window.
|
|
26
|
+
*/
|
|
27
|
+
function isProcessAlive(pid) {
|
|
28
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
// Signal 0 performs the permission and existence checks without
|
|
33
|
+
// delivering a signal.
|
|
34
|
+
process.kill(pid, 0);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
// EPERM means the process exists but belongs to another user.
|
|
39
|
+
return (typeof error === 'object' &&
|
|
40
|
+
error !== null &&
|
|
41
|
+
'code' in error &&
|
|
42
|
+
error.code === 'EPERM');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Recorded sessions that this ccmanager run may offer to launch again.
|
|
47
|
+
*
|
|
48
|
+
* @param options.projectPath - When given, only sessions of that repository are
|
|
49
|
+
* returned (single-project mode). Omit it to consider every recorded project,
|
|
50
|
+
* which is what multi-project mode does.
|
|
51
|
+
*/
|
|
52
|
+
export function listRestorableSessions(options = {}) {
|
|
53
|
+
return sessionRestoreStore
|
|
54
|
+
.list()
|
|
55
|
+
.filter(record => {
|
|
56
|
+
if (options.projectPath && record.projectPath !== options.projectPath) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
// The worktree may have been deleted while ccmanager was not running.
|
|
60
|
+
if (!existsSync(record.worktreePath)) {
|
|
61
|
+
logger.info(`Skipping session restore for missing worktree: ${record.worktreePath}`);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return !isProcessAlive(record.ownerPid);
|
|
65
|
+
})
|
|
66
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Name of the command preset a record will be launched with, for display.
|
|
70
|
+
* Resolved from the current configuration rather than stored alongside the
|
|
71
|
+
* record, so a renamed preset shows its current name. Records whose preset no
|
|
72
|
+
* longer exists fall back to the default preset, as launching does.
|
|
73
|
+
*/
|
|
74
|
+
export function describeRecordPreset(record) {
|
|
75
|
+
const preset = record.presetId
|
|
76
|
+
? Either.getOrElse(configReader.getPresetByIdEffect(record.presetId), () => undefined)
|
|
77
|
+
: undefined;
|
|
78
|
+
return preset?.name ?? configReader.getDefaultPreset()?.name ?? 'default';
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Launch each recorded session again, in the session manager the running
|
|
82
|
+
* ccmanager will look at for its project.
|
|
83
|
+
*
|
|
84
|
+
* Sessions are started one at a time: each one is numbered relative to the
|
|
85
|
+
* sessions already present in its worktree, which only holds if they are not
|
|
86
|
+
* created concurrently.
|
|
87
|
+
*
|
|
88
|
+
* Every record is dropped from the durable store as it is processed — a
|
|
89
|
+
* successfully restored session records itself anew under its new id, and a
|
|
90
|
+
* failed one must not keep being offered on every subsequent start.
|
|
91
|
+
*/
|
|
92
|
+
export async function restoreSessions(records, options) {
|
|
93
|
+
const failures = [];
|
|
94
|
+
let restored = 0;
|
|
95
|
+
for (const record of records) {
|
|
96
|
+
sessionRestoreStore.forget(record.id);
|
|
97
|
+
// Single-project mode keeps every session in the one global manager;
|
|
98
|
+
// multi-project mode keeps a manager per project.
|
|
99
|
+
const manager = globalSessionOrchestrator.getManagerForProject(options.multiProject ? record.projectPath : undefined);
|
|
100
|
+
const sessionEffect = record.devcontainerConfig
|
|
101
|
+
? manager.createSessionWithDevcontainerEffect(record.worktreePath, record.devcontainerConfig, record.presetId)
|
|
102
|
+
: manager.createSessionWithPresetEffect(record.worktreePath, record.presetId);
|
|
103
|
+
const result = await Effect.runPromise(Effect.either(sessionEffect));
|
|
104
|
+
if (result._tag === 'Left') {
|
|
105
|
+
const message = formatErrorMessage(result.left);
|
|
106
|
+
logger.error(`Failed to restore session in ${record.worktreePath}: ${message}`);
|
|
107
|
+
failures.push({ record, message });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (record.sessionName) {
|
|
111
|
+
manager.renameSession(result.right.id, record.sessionName);
|
|
112
|
+
}
|
|
113
|
+
restored++;
|
|
114
|
+
}
|
|
115
|
+
return { restored, failures };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Forget the offered sessions without launching them, so declining the offer
|
|
119
|
+
* does not make it come back on the next start.
|
|
120
|
+
*/
|
|
121
|
+
export function discardRestorableSessions(records) {
|
|
122
|
+
sessionRestoreStore.forget(...records.map(record => record.id));
|
|
123
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|