github-issue-tower-defence-management 1.113.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 +14 -0
- package/README.md +2 -2
- package/bin/adapter/entry-points/console/consoleReadApi.js +11 -1
- package/bin/adapter/entry-points/console/consoleReadApi.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-CEJmPNRK.css → index-Blb_xqIx.css} +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-DQyZ8KpG.js +101 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
- 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/entry-points/console/consoleReadApi.test.ts +82 -0
- package/src/adapter/entry-points/console/consoleReadApi.ts +21 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.stories.tsx +16 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.test.tsx +61 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.tsx +59 -5
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleReferenceLink.stories.tsx +76 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleReferenceLink.test.tsx +56 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleReferenceLink.tsx +39 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentList.tsx +4 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.stories.tsx +24 -4
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.test.tsx +13 -3
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +6 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +4 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleItemDetailData.test.ts +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleItemDetailData.ts +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleReferenceLink.test.ts +48 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleReferenceLink.ts +39 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +25 -2
- package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +2 -1
- package/src/adapter/entry-points/console/ui/src/features/console/logic/references.test.ts +80 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/references.ts +44 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/types.ts +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +12 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleReferenceLinkContainer.tsx +25 -0
- package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +40 -0
- package/src/adapter/entry-points/console/ui/src/index.css +23 -0
- package/src/adapter/entry-points/console/ui-dist/assets/{index-CEJmPNRK.css → index-Blb_xqIx.css} +1 -1
- package/src/adapter/entry-points/console/ui-dist/assets/index-DQyZ8KpG.js +101 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
- 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/entry-points/console/consoleReadApi.d.ts +1 -0
- package/types/adapter/entry-points/console/consoleReadApi.d.ts.map +1 -1
- 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
- package/bin/adapter/entry-points/console/ui-dist/assets/index-DfNrA5uV.js +0 -101
- package/src/adapter/entry-points/console/ui-dist/assets/index-DfNrA5uV.js +0 -101
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>TDPM Console</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-DQyZ8KpG.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Blb_xqIx.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
|
@@ -5,11 +5,13 @@ import { FileSystemInteractiveLiveSessionTranscriptResolver } from './FileSystem
|
|
|
5
5
|
|
|
6
6
|
describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
|
|
7
7
|
let configRoot: string;
|
|
8
|
+
let sharedProjectsDirectory: string;
|
|
8
9
|
|
|
9
10
|
beforeEach(() => {
|
|
10
11
|
configRoot = fs.mkdtempSync(
|
|
11
12
|
path.join(os.tmpdir(), 'interactive-transcript-'),
|
|
12
13
|
);
|
|
14
|
+
sharedProjectsDirectory = path.join(configRoot, 'shared', 'projects');
|
|
13
15
|
});
|
|
14
16
|
|
|
15
17
|
afterEach(() => {
|
|
@@ -17,14 +19,13 @@ describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
|
|
|
17
19
|
});
|
|
18
20
|
|
|
19
21
|
const writeTranscript = (params: {
|
|
20
|
-
|
|
22
|
+
projectsDirectory: string;
|
|
21
23
|
cwdSlug: string;
|
|
22
24
|
sessionId: string;
|
|
23
25
|
mtimeEpochSeconds?: number;
|
|
24
26
|
}): string => {
|
|
25
27
|
const projectDirectory = path.join(
|
|
26
|
-
params.
|
|
27
|
-
'projects',
|
|
28
|
+
params.projectsDirectory,
|
|
28
29
|
params.cwdSlug,
|
|
29
30
|
);
|
|
30
31
|
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
@@ -40,40 +41,100 @@ describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
|
|
|
40
41
|
return filePath;
|
|
41
42
|
};
|
|
42
43
|
|
|
43
|
-
it('resolves a transcript by config dir and session id
|
|
44
|
+
it('resolves a resume-case transcript by config dir and session id', () => {
|
|
44
45
|
const configDir = path.join(configRoot, 'workbench');
|
|
45
46
|
const filePath = writeTranscript({
|
|
46
|
-
configDir,
|
|
47
|
+
projectsDirectory: path.join(configDir, 'projects'),
|
|
47
48
|
cwdSlug: '-home-user',
|
|
48
49
|
sessionId: 'wb-uuid',
|
|
49
50
|
});
|
|
50
|
-
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
51
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
52
|
+
sharedProjectsDirectory,
|
|
53
|
+
);
|
|
51
54
|
|
|
52
55
|
const result = resolver.resolveTranscriptPaths([
|
|
53
|
-
{
|
|
56
|
+
{
|
|
57
|
+
sessionName: 'workbench',
|
|
58
|
+
sessionId: 'wb-uuid',
|
|
59
|
+
candidateSessionIds: ['wb-uuid'],
|
|
60
|
+
configDir,
|
|
61
|
+
},
|
|
54
62
|
]);
|
|
55
63
|
|
|
56
64
|
expect(result.get('workbench')).toBe(filePath);
|
|
57
65
|
});
|
|
58
66
|
|
|
59
|
-
it('
|
|
67
|
+
it('resolves a rotated-id transcript via a later candidate session id', () => {
|
|
68
|
+
const configDir = path.join(configRoot, 'workbench');
|
|
69
|
+
const rotatedPath = writeTranscript({
|
|
70
|
+
projectsDirectory: path.join(configDir, 'projects'),
|
|
71
|
+
cwdSlug: '-home-user',
|
|
72
|
+
sessionId: 'rotated-uuid',
|
|
73
|
+
});
|
|
74
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
75
|
+
sharedProjectsDirectory,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const result = resolver.resolveTranscriptPaths([
|
|
79
|
+
{
|
|
80
|
+
sessionName: 'workbench',
|
|
81
|
+
sessionId: 'launch-uuid',
|
|
82
|
+
candidateSessionIds: ['rotated-uuid', 'launch-uuid'],
|
|
83
|
+
configDir,
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
expect(result.get('workbench')).toBe(rotatedPath);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('resolves a transcript that lives under the shared projects directory', () => {
|
|
91
|
+
const configDir = path.join(configRoot, 'workbench');
|
|
92
|
+
const sharedPath = writeTranscript({
|
|
93
|
+
projectsDirectory: sharedProjectsDirectory,
|
|
94
|
+
cwdSlug: '-home-user',
|
|
95
|
+
sessionId: 'rotated-uuid',
|
|
96
|
+
});
|
|
97
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
98
|
+
sharedProjectsDirectory,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const result = resolver.resolveTranscriptPaths([
|
|
102
|
+
{
|
|
103
|
+
sessionName: 'workbench',
|
|
104
|
+
sessionId: 'launch-uuid',
|
|
105
|
+
candidateSessionIds: ['rotated-uuid', 'launch-uuid'],
|
|
106
|
+
configDir,
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
expect(result.get('workbench')).toBe(sharedPath);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('prefers the most recently modified match across both projects roots', () => {
|
|
60
114
|
const configDir = path.join(configRoot, 'workbench');
|
|
61
115
|
writeTranscript({
|
|
62
|
-
configDir,
|
|
63
|
-
cwdSlug: '-home-user
|
|
64
|
-
sessionId: '
|
|
116
|
+
projectsDirectory: path.join(configDir, 'projects'),
|
|
117
|
+
cwdSlug: '-home-user',
|
|
118
|
+
sessionId: 'rotated-uuid',
|
|
65
119
|
mtimeEpochSeconds: 1700000000,
|
|
66
120
|
});
|
|
67
121
|
const newerPath = writeTranscript({
|
|
68
|
-
|
|
69
|
-
cwdSlug: '-home-user
|
|
70
|
-
sessionId: '
|
|
122
|
+
projectsDirectory: sharedProjectsDirectory,
|
|
123
|
+
cwdSlug: '-home-user',
|
|
124
|
+
sessionId: 'rotated-uuid',
|
|
71
125
|
mtimeEpochSeconds: 1700000500,
|
|
72
126
|
});
|
|
73
|
-
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
127
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
128
|
+
sharedProjectsDirectory,
|
|
129
|
+
);
|
|
74
130
|
|
|
75
131
|
const result = resolver.resolveTranscriptPaths([
|
|
76
|
-
{
|
|
132
|
+
{
|
|
133
|
+
sessionName: 'workbench',
|
|
134
|
+
sessionId: 'launch-uuid',
|
|
135
|
+
candidateSessionIds: ['rotated-uuid', 'launch-uuid'],
|
|
136
|
+
configDir,
|
|
137
|
+
},
|
|
77
138
|
]);
|
|
78
139
|
|
|
79
140
|
expect(result.get('workbench')).toBe(newerPath);
|
|
@@ -83,26 +144,30 @@ describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
|
|
|
83
144
|
const workbenchConfig = path.join(configRoot, 'workbench');
|
|
84
145
|
const controlRoomConfig = path.join(configRoot, 'control-room');
|
|
85
146
|
const workbenchPath = writeTranscript({
|
|
86
|
-
|
|
147
|
+
projectsDirectory: path.join(workbenchConfig, 'projects'),
|
|
87
148
|
cwdSlug: '-home-user',
|
|
88
149
|
sessionId: 'wb-uuid',
|
|
89
150
|
});
|
|
90
151
|
const controlRoomPath = writeTranscript({
|
|
91
|
-
|
|
152
|
+
projectsDirectory: path.join(controlRoomConfig, 'projects'),
|
|
92
153
|
cwdSlug: '-home-user',
|
|
93
154
|
sessionId: 'cr-uuid',
|
|
94
155
|
});
|
|
95
|
-
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
156
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
157
|
+
sharedProjectsDirectory,
|
|
158
|
+
);
|
|
96
159
|
|
|
97
160
|
const result = resolver.resolveTranscriptPaths([
|
|
98
161
|
{
|
|
99
162
|
sessionName: 'workbench',
|
|
100
163
|
sessionId: 'wb-uuid',
|
|
164
|
+
candidateSessionIds: ['wb-uuid'],
|
|
101
165
|
configDir: workbenchConfig,
|
|
102
166
|
},
|
|
103
167
|
{
|
|
104
168
|
sessionName: 'control-room',
|
|
105
169
|
sessionId: 'cr-uuid',
|
|
170
|
+
candidateSessionIds: ['cr-uuid'],
|
|
106
171
|
configDir: controlRoomConfig,
|
|
107
172
|
},
|
|
108
173
|
]);
|
|
@@ -111,34 +176,50 @@ describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
|
|
|
111
176
|
expect(result.get('control-room')).toBe(controlRoomPath);
|
|
112
177
|
});
|
|
113
178
|
|
|
114
|
-
it('omits a session whose transcript file is absent', () => {
|
|
179
|
+
it('omits a session whose transcript file is absent in both roots', () => {
|
|
115
180
|
const configDir = path.join(configRoot, 'workbench');
|
|
116
181
|
fs.mkdirSync(path.join(configDir, 'projects', '-home-user'), {
|
|
117
182
|
recursive: true,
|
|
118
183
|
});
|
|
119
|
-
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
184
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
185
|
+
sharedProjectsDirectory,
|
|
186
|
+
);
|
|
120
187
|
|
|
121
188
|
const result = resolver.resolveTranscriptPaths([
|
|
122
|
-
{
|
|
189
|
+
{
|
|
190
|
+
sessionName: 'workbench',
|
|
191
|
+
sessionId: 'missing-uuid',
|
|
192
|
+
candidateSessionIds: ['missing-uuid'],
|
|
193
|
+
configDir,
|
|
194
|
+
},
|
|
123
195
|
]);
|
|
124
196
|
|
|
125
197
|
expect(result.has('workbench')).toBe(false);
|
|
126
198
|
});
|
|
127
199
|
|
|
128
|
-
it('omits a session
|
|
200
|
+
it('omits a session when neither projects directory exists', () => {
|
|
129
201
|
const configDir = path.join(configRoot, 'workbench');
|
|
130
202
|
fs.mkdirSync(configDir, { recursive: true });
|
|
131
|
-
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
203
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
204
|
+
sharedProjectsDirectory,
|
|
205
|
+
);
|
|
132
206
|
|
|
133
207
|
const result = resolver.resolveTranscriptPaths([
|
|
134
|
-
{
|
|
208
|
+
{
|
|
209
|
+
sessionName: 'workbench',
|
|
210
|
+
sessionId: 'wb-uuid',
|
|
211
|
+
candidateSessionIds: ['wb-uuid'],
|
|
212
|
+
configDir,
|
|
213
|
+
},
|
|
135
214
|
]);
|
|
136
215
|
|
|
137
216
|
expect(result.has('workbench')).toBe(false);
|
|
138
217
|
});
|
|
139
218
|
|
|
140
219
|
it('returns an empty map for an empty session list', () => {
|
|
141
|
-
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
220
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
221
|
+
sharedProjectsDirectory,
|
|
222
|
+
);
|
|
142
223
|
|
|
143
224
|
const result = resolver.resolveTranscriptPaths([]);
|
|
144
225
|
|
|
@@ -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
|
});
|