github-issue-tower-defence-management 1.114.0 → 1.114.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/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js +36 -27
- package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js.map +1 -1
- package/bin/adapter/repositories/FileSystemSessionRecordReader.js +69 -0
- package/bin/adapter/repositories/FileSystemSessionRecordReader.js.map +1 -0
- package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js +9 -2
- package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js.map +1 -1
- package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js +15 -0
- package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js.map +1 -1
- package/bin/domain/usecases/adapter-interfaces/SessionRecordReader.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/SessionRecordReader.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.test.ts +107 -26
- package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.ts +49 -29
- package/src/adapter/repositories/FileSystemSessionRecordReader.test.ts +59 -0
- package/src/adapter/repositories/FileSystemSessionRecordReader.ts +31 -0
- package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +51 -0
- package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.ts +10 -1
- package/src/domain/entities/InteractiveLiveSession.ts +1 -0
- package/src/domain/entities/LiveSessionProcessSnapshot.ts +1 -0
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +4 -0
- package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.test.ts +66 -59
- package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.ts +19 -0
- package/src/domain/usecases/adapter-interfaces/SessionRecordReader.ts +3 -0
- package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts +4 -11
- package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts.map +1 -1
- package/types/adapter/repositories/FileSystemSessionRecordReader.d.ts +5 -0
- package/types/adapter/repositories/FileSystemSessionRecordReader.d.ts.map +1 -0
- package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts +3 -1
- package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts.map +1 -1
- package/types/domain/entities/InteractiveLiveSession.d.ts +1 -0
- package/types/domain/entities/InteractiveLiveSession.d.ts.map +1 -1
- package/types/domain/entities/LiveSessionProcessSnapshot.d.ts +1 -0
- package/types/domain/entities/LiveSessionProcessSnapshot.d.ts.map +1 -1
- package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts +1 -0
- package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/SessionRecordReader.d.ts +4 -0
- package/types/domain/usecases/adapter-interfaces/SessionRecordReader.d.ts.map +1 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
2
3
|
import * as path from 'path';
|
|
3
4
|
import { InteractiveLiveSession } from '../../domain/entities/InteractiveLiveSession';
|
|
4
5
|
import { InteractiveLiveSessionTranscriptResolver } from '../../domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver';
|
|
@@ -12,18 +13,14 @@ const modifiedEpochMs = (filePath: string): number | null => {
|
|
|
12
13
|
}
|
|
13
14
|
};
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
* environment. The transcript lives at
|
|
19
|
-
* `<configDir>/projects/<cwd-slug>/<sessionId>.jsonl`; this resolver scans the
|
|
20
|
-
* `projects` subdirectories for a file named `<sessionId>.jsonl` and returns the
|
|
21
|
-
* most recently modified match. Because resolution is keyed on the process
|
|
22
|
-
* session id rather than on a session name or issue URL, a plain-named session
|
|
23
|
-
* (for example one named `workbench`) resolves just as well as an issue-url
|
|
24
|
-
* named one.
|
|
25
|
-
*/
|
|
16
|
+
const defaultSharedProjectsDirectory = (): string =>
|
|
17
|
+
path.join(os.homedir(), '.claude', 'projects');
|
|
18
|
+
|
|
26
19
|
export class FileSystemInteractiveLiveSessionTranscriptResolver implements InteractiveLiveSessionTranscriptResolver {
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly sharedProjectsDirectory: string = defaultSharedProjectsDirectory(),
|
|
22
|
+
) {}
|
|
23
|
+
|
|
27
24
|
resolveTranscriptPaths = (
|
|
28
25
|
sessions: InteractiveLiveSession[],
|
|
29
26
|
): Map<string, string> => {
|
|
@@ -40,36 +37,59 @@ export class FileSystemInteractiveLiveSessionTranscriptResolver implements Inter
|
|
|
40
37
|
private resolveTranscriptPath = (
|
|
41
38
|
session: InteractiveLiveSession,
|
|
42
39
|
): string | null => {
|
|
43
|
-
const
|
|
40
|
+
const projectsDirectories = this.listProjectsDirectories(session.configDir);
|
|
41
|
+
let latestPath: string | null = null;
|
|
42
|
+
let latestEpochMs = -Infinity;
|
|
43
|
+
for (const candidateSessionId of session.candidateSessionIds) {
|
|
44
|
+
const fileName = `${candidateSessionId}.jsonl`;
|
|
45
|
+
for (const projectsDirectory of projectsDirectories) {
|
|
46
|
+
for (const candidate of this.listCandidatePaths(
|
|
47
|
+
projectsDirectory,
|
|
48
|
+
fileName,
|
|
49
|
+
)) {
|
|
50
|
+
const epochMs = modifiedEpochMs(candidate);
|
|
51
|
+
if (epochMs === null) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (epochMs > latestEpochMs) {
|
|
55
|
+
latestEpochMs = epochMs;
|
|
56
|
+
latestPath = candidate;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return latestPath;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
private listProjectsDirectories = (configDir: string): string[] => {
|
|
65
|
+
const perSessionProjectsDirectory = path.join(configDir, 'projects');
|
|
66
|
+
if (perSessionProjectsDirectory === this.sharedProjectsDirectory) {
|
|
67
|
+
return [perSessionProjectsDirectory];
|
|
68
|
+
}
|
|
69
|
+
return [perSessionProjectsDirectory, this.sharedProjectsDirectory];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
private listCandidatePaths = (
|
|
73
|
+
projectsDirectory: string,
|
|
74
|
+
fileName: string,
|
|
75
|
+
): string[] => {
|
|
44
76
|
let projectEntries: fs.Dirent[];
|
|
45
77
|
try {
|
|
46
78
|
projectEntries = fs.readdirSync(projectsDirectory, {
|
|
47
79
|
withFileTypes: true,
|
|
48
80
|
});
|
|
49
81
|
} catch {
|
|
50
|
-
return
|
|
82
|
+
return [];
|
|
51
83
|
}
|
|
52
|
-
const
|
|
53
|
-
let latestPath: string | null = null;
|
|
54
|
-
let latestEpochMs = -Infinity;
|
|
84
|
+
const candidatePaths: string[] = [];
|
|
55
85
|
for (const projectEntry of projectEntries) {
|
|
56
86
|
if (!projectEntry.isDirectory()) {
|
|
57
87
|
continue;
|
|
58
88
|
}
|
|
59
|
-
|
|
60
|
-
projectsDirectory,
|
|
61
|
-
projectEntry.name,
|
|
62
|
-
fileName,
|
|
89
|
+
candidatePaths.push(
|
|
90
|
+
path.join(projectsDirectory, projectEntry.name, fileName),
|
|
63
91
|
);
|
|
64
|
-
const epochMs = modifiedEpochMs(candidate);
|
|
65
|
-
if (epochMs === null) {
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
if (epochMs > latestEpochMs) {
|
|
69
|
-
latestEpochMs = epochMs;
|
|
70
|
-
latestPath = candidate;
|
|
71
|
-
}
|
|
72
92
|
}
|
|
73
|
-
return
|
|
93
|
+
return candidatePaths;
|
|
74
94
|
};
|
|
75
95
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { FileSystemSessionRecordReader } from './FileSystemSessionRecordReader';
|
|
5
|
+
|
|
6
|
+
describe('FileSystemSessionRecordReader', () => {
|
|
7
|
+
let configDir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-record-'));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
fs.rmSync(configDir, { force: true, recursive: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const writeRecord = (pid: number, record: object): void => {
|
|
18
|
+
const sessionsDirectory = path.join(configDir, 'sessions');
|
|
19
|
+
fs.mkdirSync(sessionsDirectory, { recursive: true });
|
|
20
|
+
fs.writeFileSync(
|
|
21
|
+
path.join(sessionsDirectory, `${pid}.json`),
|
|
22
|
+
JSON.stringify(record),
|
|
23
|
+
'utf8',
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
it('returns the current session id recorded for the pid', () => {
|
|
28
|
+
writeRecord(201, { pid: 201, sessionId: 'rotated-uuid', status: 'busy' });
|
|
29
|
+
const reader = new FileSystemSessionRecordReader();
|
|
30
|
+
|
|
31
|
+
expect(reader.readCurrentSessionId(configDir, 201)).toBe('rotated-uuid');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns null when the session record file is absent', () => {
|
|
35
|
+
const reader = new FileSystemSessionRecordReader();
|
|
36
|
+
|
|
37
|
+
expect(reader.readCurrentSessionId(configDir, 999)).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns null when the session record is not valid json', () => {
|
|
41
|
+
const sessionsDirectory = path.join(configDir, 'sessions');
|
|
42
|
+
fs.mkdirSync(sessionsDirectory, { recursive: true });
|
|
43
|
+
fs.writeFileSync(
|
|
44
|
+
path.join(sessionsDirectory, '201.json'),
|
|
45
|
+
'not json',
|
|
46
|
+
'utf8',
|
|
47
|
+
);
|
|
48
|
+
const reader = new FileSystemSessionRecordReader();
|
|
49
|
+
|
|
50
|
+
expect(reader.readCurrentSessionId(configDir, 201)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns null when the record has no string session id', () => {
|
|
54
|
+
writeRecord(201, { pid: 201, sessionId: 42 });
|
|
55
|
+
const reader = new FileSystemSessionRecordReader();
|
|
56
|
+
|
|
57
|
+
expect(reader.readCurrentSessionId(configDir, 201)).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { SessionRecordReader } from '../../domain/usecases/adapter-interfaces/SessionRecordReader';
|
|
4
|
+
|
|
5
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
6
|
+
typeof value === 'object' && value !== null;
|
|
7
|
+
|
|
8
|
+
export class FileSystemSessionRecordReader implements SessionRecordReader {
|
|
9
|
+
readCurrentSessionId = (configDir: string, pid: number): string | null => {
|
|
10
|
+
const recordPath = path.join(configDir, 'sessions', `${pid}.json`);
|
|
11
|
+
let content: string;
|
|
12
|
+
try {
|
|
13
|
+
content = fs.readFileSync(recordPath, 'utf8');
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
let parsed: unknown;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(content);
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (!isRecord(parsed)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const sessionId = parsed.sessionId;
|
|
27
|
+
return typeof sessionId === 'string' && sessionId.length > 0
|
|
28
|
+
? sessionId
|
|
29
|
+
: null;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { LocalProcessLiveSessionProcessSnapshotProvider } from './LocalProcessLiveSessionProcessSnapshotProvider';
|
|
2
2
|
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
3
3
|
import { ProcessEnvironReader } from '../../domain/usecases/adapter-interfaces/ProcessEnvironReader';
|
|
4
|
+
import { SessionRecordReader } from '../../domain/usecases/adapter-interfaces/SessionRecordReader';
|
|
4
5
|
|
|
5
6
|
describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
|
|
6
7
|
const makeRunner = (
|
|
@@ -29,6 +30,13 @@ describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
|
|
|
29
30
|
readEnviron: (pid: number) => byPid[pid] ?? null,
|
|
30
31
|
});
|
|
31
32
|
|
|
33
|
+
const makeSessionRecordReader = (
|
|
34
|
+
byPid: Record<number, string>,
|
|
35
|
+
): SessionRecordReader => ({
|
|
36
|
+
readCurrentSessionId: (_configDir: string, pid: number) =>
|
|
37
|
+
byPid[pid] ?? null,
|
|
38
|
+
});
|
|
39
|
+
|
|
32
40
|
it('builds a snapshot of sessions, pane pids, and processes with environment', async () => {
|
|
33
41
|
const runner = makeRunner({
|
|
34
42
|
'tmux list-sessions -F #{session_name}': {
|
|
@@ -56,9 +64,14 @@ describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
|
|
|
56
64
|
CLAUDE_CONFIG_DIR: '/config/workbench',
|
|
57
65
|
},
|
|
58
66
|
});
|
|
67
|
+
const sessionRecordReader = makeSessionRecordReader({
|
|
68
|
+
101: 'issue-rotated-uuid',
|
|
69
|
+
201: 'wb-rotated-uuid',
|
|
70
|
+
});
|
|
59
71
|
const provider = new LocalProcessLiveSessionProcessSnapshotProvider(
|
|
60
72
|
runner,
|
|
61
73
|
environReader,
|
|
74
|
+
sessionRecordReader,
|
|
62
75
|
);
|
|
63
76
|
|
|
64
77
|
const snapshot = await provider.getSnapshot();
|
|
@@ -75,6 +88,7 @@ describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
|
|
|
75
88
|
ppid: 200,
|
|
76
89
|
commandLine: 'claude --name workbench',
|
|
77
90
|
sessionId: 'wb-uuid',
|
|
91
|
+
currentSessionId: 'wb-rotated-uuid',
|
|
78
92
|
configDir: '/config/workbench',
|
|
79
93
|
});
|
|
80
94
|
expect(snapshot.processes).toContainEqual({
|
|
@@ -82,10 +96,44 @@ describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
|
|
|
82
96
|
ppid: 1,
|
|
83
97
|
commandLine: 'shell',
|
|
84
98
|
sessionId: null,
|
|
99
|
+
currentSessionId: null,
|
|
85
100
|
configDir: null,
|
|
86
101
|
});
|
|
87
102
|
});
|
|
88
103
|
|
|
104
|
+
it('leaves currentSessionId null when the session record is absent', async () => {
|
|
105
|
+
const runner = makeRunner({
|
|
106
|
+
'tmux list-sessions -F #{session_name}': { stdout: 'workbench\n' },
|
|
107
|
+
'tmux list-panes -t workbench -F #{pane_pid}': { stdout: '200\n' },
|
|
108
|
+
'ps -eo pid=,ppid=,args=': {
|
|
109
|
+
stdout: ' 201 200 claude --name workbench',
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
const provider = new LocalProcessLiveSessionProcessSnapshotProvider(
|
|
113
|
+
runner,
|
|
114
|
+
makeEnvironReader({
|
|
115
|
+
201: {
|
|
116
|
+
CLAUDE_CODE_SESSION_ID: 'wb-uuid',
|
|
117
|
+
CLAUDE_CONFIG_DIR: '/config/workbench',
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
makeSessionRecordReader({}),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const snapshot = await provider.getSnapshot();
|
|
124
|
+
|
|
125
|
+
expect(snapshot.processes).toEqual([
|
|
126
|
+
{
|
|
127
|
+
pid: 201,
|
|
128
|
+
ppid: 200,
|
|
129
|
+
commandLine: 'claude --name workbench',
|
|
130
|
+
sessionId: 'wb-uuid',
|
|
131
|
+
currentSessionId: null,
|
|
132
|
+
configDir: '/config/workbench',
|
|
133
|
+
},
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
|
|
89
137
|
it('returns an empty snapshot when tmux reports no sessions', async () => {
|
|
90
138
|
const runner = makeRunner({
|
|
91
139
|
'tmux list-sessions -F #{session_name}': { stdout: '' },
|
|
@@ -94,6 +142,7 @@ describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
|
|
|
94
142
|
const provider = new LocalProcessLiveSessionProcessSnapshotProvider(
|
|
95
143
|
runner,
|
|
96
144
|
makeEnvironReader({}),
|
|
145
|
+
makeSessionRecordReader({}),
|
|
97
146
|
);
|
|
98
147
|
|
|
99
148
|
const snapshot = await provider.getSnapshot();
|
|
@@ -121,6 +170,7 @@ describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
|
|
|
121
170
|
CLAUDE_CONFIG_DIR: '/config/workbench',
|
|
122
171
|
},
|
|
123
172
|
}),
|
|
173
|
+
makeSessionRecordReader({ 201: 'wb-rotated-uuid' }),
|
|
124
174
|
);
|
|
125
175
|
|
|
126
176
|
const snapshot = await provider.getSnapshot();
|
|
@@ -131,6 +181,7 @@ describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
|
|
|
131
181
|
ppid: 200,
|
|
132
182
|
commandLine: 'claude --name workbench',
|
|
133
183
|
sessionId: 'wb-uuid',
|
|
184
|
+
currentSessionId: 'wb-rotated-uuid',
|
|
134
185
|
configDir: '/config/workbench',
|
|
135
186
|
},
|
|
136
187
|
]);
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
2
2
|
import { LiveSessionProcessSnapshotProvider } from '../../domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider';
|
|
3
3
|
import { ProcessEnvironReader } from '../../domain/usecases/adapter-interfaces/ProcessEnvironReader';
|
|
4
|
+
import { SessionRecordReader } from '../../domain/usecases/adapter-interfaces/SessionRecordReader';
|
|
4
5
|
import {
|
|
5
6
|
LiveSessionPaneProcess,
|
|
6
7
|
LiveSessionProcessInfo,
|
|
7
8
|
LiveSessionProcessSnapshot,
|
|
8
9
|
} from '../../domain/entities/LiveSessionProcessSnapshot';
|
|
10
|
+
import { FileSystemSessionRecordReader } from './FileSystemSessionRecordReader';
|
|
9
11
|
|
|
10
12
|
const SESSION_ID_ENV_KEY = 'CLAUDE_CODE_SESSION_ID';
|
|
11
13
|
const CONFIG_DIR_ENV_KEY = 'CLAUDE_CONFIG_DIR';
|
|
@@ -21,6 +23,7 @@ export class LocalProcessLiveSessionProcessSnapshotProvider implements LiveSessi
|
|
|
21
23
|
constructor(
|
|
22
24
|
private readonly localCommandRunner: LocalCommandRunner,
|
|
23
25
|
private readonly environReader: ProcessEnvironReader,
|
|
26
|
+
private readonly sessionRecordReader: SessionRecordReader = new FileSystemSessionRecordReader(),
|
|
24
27
|
) {}
|
|
25
28
|
|
|
26
29
|
getSnapshot = async (): Promise<LiveSessionProcessSnapshot> => {
|
|
@@ -80,12 +83,18 @@ export class LocalProcessLiveSessionProcessSnapshotProvider implements LiveSessi
|
|
|
80
83
|
const ppid = Number(match[2]);
|
|
81
84
|
const commandLine = match[3].trim();
|
|
82
85
|
const environ = this.environReader.readEnviron(pid);
|
|
86
|
+
const configDir = environ?.[CONFIG_DIR_ENV_KEY] ?? null;
|
|
87
|
+
const currentSessionId =
|
|
88
|
+
configDir === null
|
|
89
|
+
? null
|
|
90
|
+
: this.sessionRecordReader.readCurrentSessionId(configDir, pid);
|
|
83
91
|
processes.push({
|
|
84
92
|
pid,
|
|
85
93
|
ppid,
|
|
86
94
|
commandLine,
|
|
87
95
|
sessionId: environ?.[SESSION_ID_ENV_KEY] ?? null,
|
|
88
|
-
|
|
96
|
+
currentSessionId,
|
|
97
|
+
configDir,
|
|
89
98
|
});
|
|
90
99
|
}
|
|
91
100
|
return processes;
|
|
@@ -60,6 +60,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
60
60
|
const sessionFor = (sessionName: string): InteractiveLiveSession => ({
|
|
61
61
|
sessionName,
|
|
62
62
|
sessionId: `${sessionName}-uuid`,
|
|
63
|
+
candidateSessionIds: [`${sessionName}-uuid`],
|
|
63
64
|
configDir: `/config/${sessionName}`,
|
|
64
65
|
});
|
|
65
66
|
|
|
@@ -80,6 +81,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
80
81
|
ppid: 100 + index * 10,
|
|
81
82
|
commandLine: `claude --name ${sessionName}`,
|
|
82
83
|
sessionId: `${sessionName}-uuid`,
|
|
84
|
+
currentSessionId: `${sessionName}-uuid`,
|
|
83
85
|
configDir: `/config/${sessionName}`,
|
|
84
86
|
}));
|
|
85
87
|
return { sessions, processes };
|
|
@@ -299,6 +301,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
299
301
|
commandLine:
|
|
300
302
|
'claude --verbose -p Take ownership of https://example.com/issues/1 and finish it',
|
|
301
303
|
sessionId: 'aw-uuid',
|
|
304
|
+
currentSessionId: 'aw-uuid',
|
|
302
305
|
configDir: '/config/aw',
|
|
303
306
|
},
|
|
304
307
|
],
|
|
@@ -407,6 +410,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
407
410
|
expect(sessionFor('workbench')).toEqual({
|
|
408
411
|
sessionName: 'workbench',
|
|
409
412
|
sessionId: 'workbench-uuid',
|
|
413
|
+
candidateSessionIds: ['workbench-uuid'],
|
|
410
414
|
configDir: '/config/workbench',
|
|
411
415
|
});
|
|
412
416
|
});
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import { ResolveInteractiveLiveSessionsUseCase } from './ResolveInteractiveLiveSessionsUseCase';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
LiveSessionProcessInfo,
|
|
4
|
+
LiveSessionProcessSnapshot,
|
|
5
|
+
} from '../entities/LiveSessionProcessSnapshot';
|
|
3
6
|
|
|
4
7
|
describe('ResolveInteractiveLiveSessionsUseCase', () => {
|
|
5
8
|
const useCase = new ResolveInteractiveLiveSessionsUseCase();
|
|
6
9
|
|
|
10
|
+
const processInfo = (
|
|
11
|
+
overrides: Partial<LiveSessionProcessInfo> &
|
|
12
|
+
Pick<LiveSessionProcessInfo, 'pid' | 'ppid' | 'commandLine'>,
|
|
13
|
+
): LiveSessionProcessInfo => ({
|
|
14
|
+
sessionId: null,
|
|
15
|
+
currentSessionId: null,
|
|
16
|
+
configDir: null,
|
|
17
|
+
...overrides,
|
|
18
|
+
});
|
|
19
|
+
|
|
7
20
|
it('resolves an issue-url-named session through its pane child process', () => {
|
|
8
21
|
const snapshot: LiveSessionProcessSnapshot = {
|
|
9
22
|
sessions: [
|
|
@@ -13,20 +26,14 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
|
|
|
13
26
|
},
|
|
14
27
|
],
|
|
15
28
|
processes: [
|
|
16
|
-
{
|
|
17
|
-
|
|
18
|
-
ppid: 1,
|
|
19
|
-
commandLine: 'tmux pane shell',
|
|
20
|
-
sessionId: null,
|
|
21
|
-
configDir: null,
|
|
22
|
-
},
|
|
23
|
-
{
|
|
29
|
+
processInfo({ pid: 100, ppid: 1, commandLine: 'tmux pane shell' }),
|
|
30
|
+
processInfo({
|
|
24
31
|
pid: 101,
|
|
25
32
|
ppid: 100,
|
|
26
33
|
commandLine: 'claude --model opus --resume abc',
|
|
27
34
|
sessionId: 'abc',
|
|
28
35
|
configDir: '/config/issues-9',
|
|
29
|
-
},
|
|
36
|
+
}),
|
|
30
37
|
],
|
|
31
38
|
};
|
|
32
39
|
|
|
@@ -36,29 +43,51 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
|
|
|
36
43
|
{
|
|
37
44
|
sessionName: 'https_//github_com/owner/repo/issues/9',
|
|
38
45
|
sessionId: 'abc',
|
|
46
|
+
candidateSessionIds: ['abc'],
|
|
39
47
|
configDir: '/config/issues-9',
|
|
40
48
|
},
|
|
41
49
|
]);
|
|
42
50
|
});
|
|
43
51
|
|
|
44
|
-
it('
|
|
52
|
+
it('puts the rotated current session id before the launch env id', () => {
|
|
45
53
|
const snapshot: LiveSessionProcessSnapshot = {
|
|
46
54
|
sessions: [{ sessionName: 'workbench', panePids: [200] }],
|
|
47
55
|
processes: [
|
|
48
|
-
{
|
|
49
|
-
pid:
|
|
50
|
-
ppid:
|
|
51
|
-
commandLine: '
|
|
52
|
-
sessionId:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
processInfo({
|
|
57
|
+
pid: 201,
|
|
58
|
+
ppid: 200,
|
|
59
|
+
commandLine: 'claude --name workbench',
|
|
60
|
+
sessionId: 'launch-uuid',
|
|
61
|
+
currentSessionId: 'rotated-uuid',
|
|
62
|
+
configDir: '/config/workbench',
|
|
63
|
+
}),
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const result = useCase.resolve(snapshot);
|
|
68
|
+
|
|
69
|
+
expect(result).toEqual([
|
|
70
|
+
{
|
|
71
|
+
sessionName: 'workbench',
|
|
72
|
+
sessionId: 'launch-uuid',
|
|
73
|
+
candidateSessionIds: ['rotated-uuid', 'launch-uuid'],
|
|
74
|
+
configDir: '/config/workbench',
|
|
75
|
+
},
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('keeps a single candidate id when the current id equals the launch id', () => {
|
|
80
|
+
const snapshot: LiveSessionProcessSnapshot = {
|
|
81
|
+
sessions: [{ sessionName: 'workbench', panePids: [200] }],
|
|
82
|
+
processes: [
|
|
83
|
+
processInfo({
|
|
56
84
|
pid: 201,
|
|
57
85
|
ppid: 200,
|
|
58
86
|
commandLine: 'claude --model opus --name workbench',
|
|
59
87
|
sessionId: 'wb-uuid',
|
|
88
|
+
currentSessionId: 'wb-uuid',
|
|
60
89
|
configDir: '/config/workbench',
|
|
61
|
-
},
|
|
90
|
+
}),
|
|
62
91
|
],
|
|
63
92
|
};
|
|
64
93
|
|
|
@@ -68,6 +97,7 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
|
|
|
68
97
|
{
|
|
69
98
|
sessionName: 'workbench',
|
|
70
99
|
sessionId: 'wb-uuid',
|
|
100
|
+
candidateSessionIds: ['wb-uuid'],
|
|
71
101
|
configDir: '/config/workbench',
|
|
72
102
|
},
|
|
73
103
|
]);
|
|
@@ -77,27 +107,15 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
|
|
|
77
107
|
const snapshot: LiveSessionProcessSnapshot = {
|
|
78
108
|
sessions: [{ sessionName: 'control-room', panePids: [300] }],
|
|
79
109
|
processes: [
|
|
80
|
-
{
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
commandLine: 'shell',
|
|
84
|
-
sessionId: null,
|
|
85
|
-
configDir: null,
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
pid: 301,
|
|
89
|
-
ppid: 300,
|
|
90
|
-
commandLine: 'node wrapper',
|
|
91
|
-
sessionId: null,
|
|
92
|
-
configDir: null,
|
|
93
|
-
},
|
|
94
|
-
{
|
|
110
|
+
processInfo({ pid: 300, ppid: 1, commandLine: 'shell' }),
|
|
111
|
+
processInfo({ pid: 301, ppid: 300, commandLine: 'node wrapper' }),
|
|
112
|
+
processInfo({
|
|
95
113
|
pid: 302,
|
|
96
114
|
ppid: 301,
|
|
97
115
|
commandLine: 'claude --name control-room',
|
|
98
116
|
sessionId: 'cr-uuid',
|
|
99
117
|
configDir: '/config/control-room',
|
|
100
|
-
},
|
|
118
|
+
}),
|
|
101
119
|
],
|
|
102
120
|
};
|
|
103
121
|
|
|
@@ -107,6 +125,7 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
|
|
|
107
125
|
{
|
|
108
126
|
sessionName: 'control-room',
|
|
109
127
|
sessionId: 'cr-uuid',
|
|
128
|
+
candidateSessionIds: ['cr-uuid'],
|
|
110
129
|
configDir: '/config/control-room',
|
|
111
130
|
},
|
|
112
131
|
]);
|
|
@@ -116,21 +135,15 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
|
|
|
116
135
|
const snapshot: LiveSessionProcessSnapshot = {
|
|
117
136
|
sessions: [{ sessionName: 'aw-host', panePids: [400] }],
|
|
118
137
|
processes: [
|
|
119
|
-
{
|
|
120
|
-
|
|
121
|
-
ppid: 1,
|
|
122
|
-
commandLine: 'shell',
|
|
123
|
-
sessionId: null,
|
|
124
|
-
configDir: null,
|
|
125
|
-
},
|
|
126
|
-
{
|
|
138
|
+
processInfo({ pid: 400, ppid: 1, commandLine: 'shell' }),
|
|
139
|
+
processInfo({
|
|
127
140
|
pid: 401,
|
|
128
141
|
ppid: 400,
|
|
129
142
|
commandLine:
|
|
130
143
|
'claude --verbose -p Take ownership of https://example.com/issues/1 and finish it',
|
|
131
144
|
sessionId: 'aw-uuid',
|
|
132
145
|
configDir: '/config/aw',
|
|
133
|
-
},
|
|
146
|
+
}),
|
|
134
147
|
],
|
|
135
148
|
};
|
|
136
149
|
|
|
@@ -143,13 +156,12 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
|
|
|
143
156
|
const snapshot: LiveSessionProcessSnapshot = {
|
|
144
157
|
sessions: [{ sessionName: 'partial', panePids: [500] }],
|
|
145
158
|
processes: [
|
|
146
|
-
{
|
|
159
|
+
processInfo({
|
|
147
160
|
pid: 501,
|
|
148
161
|
ppid: 500,
|
|
149
162
|
commandLine: 'claude --model opus',
|
|
150
|
-
sessionId: null,
|
|
151
163
|
configDir: '/config/partial',
|
|
152
|
-
},
|
|
164
|
+
}),
|
|
153
165
|
],
|
|
154
166
|
};
|
|
155
167
|
|
|
@@ -162,13 +174,13 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
|
|
|
162
174
|
const snapshot: LiveSessionProcessSnapshot = {
|
|
163
175
|
sessions: [{ sessionName: 'monitored', panePids: [600] }],
|
|
164
176
|
processes: [
|
|
165
|
-
{
|
|
177
|
+
processInfo({
|
|
166
178
|
pid: 601,
|
|
167
179
|
ppid: 600,
|
|
168
180
|
commandLine: 'node monitor.js',
|
|
169
181
|
sessionId: 'mon-uuid',
|
|
170
182
|
configDir: '/config/monitor',
|
|
171
|
-
},
|
|
183
|
+
}),
|
|
172
184
|
],
|
|
173
185
|
};
|
|
174
186
|
|
|
@@ -184,20 +196,14 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
|
|
|
184
196
|
{ sessionName: 'empty', panePids: [800] },
|
|
185
197
|
],
|
|
186
198
|
processes: [
|
|
187
|
-
{
|
|
199
|
+
processInfo({
|
|
188
200
|
pid: 701,
|
|
189
201
|
ppid: 700,
|
|
190
202
|
commandLine: 'claude --name workbench',
|
|
191
203
|
sessionId: 'wb-uuid',
|
|
192
204
|
configDir: '/config/workbench',
|
|
193
|
-
},
|
|
194
|
-
{
|
|
195
|
-
pid: 801,
|
|
196
|
-
ppid: 800,
|
|
197
|
-
commandLine: 'bash idle',
|
|
198
|
-
sessionId: null,
|
|
199
|
-
configDir: null,
|
|
200
|
-
},
|
|
205
|
+
}),
|
|
206
|
+
processInfo({ pid: 801, ppid: 800, commandLine: 'bash idle' }),
|
|
201
207
|
],
|
|
202
208
|
};
|
|
203
209
|
|
|
@@ -207,6 +213,7 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
|
|
|
207
213
|
{
|
|
208
214
|
sessionName: 'workbench',
|
|
209
215
|
sessionId: 'wb-uuid',
|
|
216
|
+
candidateSessionIds: ['wb-uuid'],
|
|
210
217
|
configDir: '/config/workbench',
|
|
211
218
|
},
|
|
212
219
|
]);
|
|
@@ -69,12 +69,31 @@ export class ResolveInteractiveLiveSessionsUseCase {
|
|
|
69
69
|
sessions.push({
|
|
70
70
|
sessionName: session.sessionName,
|
|
71
71
|
sessionId: interactiveProcess.sessionId,
|
|
72
|
+
candidateSessionIds:
|
|
73
|
+
this.collectCandidateSessionIds(interactiveProcess),
|
|
72
74
|
configDir: interactiveProcess.configDir,
|
|
73
75
|
});
|
|
74
76
|
}
|
|
75
77
|
return sessions;
|
|
76
78
|
};
|
|
77
79
|
|
|
80
|
+
private collectCandidateSessionIds = (
|
|
81
|
+
interactiveProcess: LiveSessionProcessInfo,
|
|
82
|
+
): string[] => {
|
|
83
|
+
const candidateSessionIds: string[] = [];
|
|
84
|
+
const seenSessionIds = new Set<string>();
|
|
85
|
+
for (const sessionId of [
|
|
86
|
+
interactiveProcess.currentSessionId,
|
|
87
|
+
interactiveProcess.sessionId,
|
|
88
|
+
]) {
|
|
89
|
+
if (sessionId !== null && !seenSessionIds.has(sessionId)) {
|
|
90
|
+
seenSessionIds.add(sessionId);
|
|
91
|
+
candidateSessionIds.push(sessionId);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return candidateSessionIds;
|
|
95
|
+
};
|
|
96
|
+
|
|
78
97
|
private findInteractiveProcess = (
|
|
79
98
|
panePids: number[],
|
|
80
99
|
processByPid: Map<number, LiveSessionProcessInfo>,
|