github-issue-tower-defence-management 1.114.0 → 1.114.2
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/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js +36 -27
- package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js.map +1 -1
- package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +14 -14
- package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.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 +43 -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 +153 -26
- package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.ts +49 -29
- package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +57 -5
- package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +14 -14
- 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 +142 -59
- package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.ts +50 -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/FileSystemSessionOutputActivityRepository.d.ts +8 -5
- package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.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 +13 -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
|
@@ -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,146 @@ 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 non-resume session to the descendant id file when the own launch id file is absent', () => {
|
|
91
|
+
const configDir = path.join(configRoot, 'non-resume');
|
|
92
|
+
const harnessPath = writeTranscript({
|
|
93
|
+
projectsDirectory: path.join(configDir, 'projects'),
|
|
94
|
+
cwdSlug: '-home-user',
|
|
95
|
+
sessionId: 'harness-id',
|
|
96
|
+
});
|
|
97
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
98
|
+
sharedProjectsDirectory,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const result = resolver.resolveTranscriptPaths([
|
|
102
|
+
{
|
|
103
|
+
sessionName: 'non-resume',
|
|
104
|
+
sessionId: 'launch-id',
|
|
105
|
+
candidateSessionIds: ['launch-id', 'harness-id'],
|
|
106
|
+
configDir,
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
expect(result.get('non-resume')).toBe(harnessPath);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('resolves a resume session to the own id file when descendants share the own id', () => {
|
|
114
|
+
const configDir = path.join(configRoot, 'resume');
|
|
115
|
+
const ownPath = writeTranscript({
|
|
116
|
+
projectsDirectory: path.join(configDir, 'projects'),
|
|
117
|
+
cwdSlug: '-home-user',
|
|
118
|
+
sessionId: 'own-id',
|
|
119
|
+
});
|
|
120
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
121
|
+
sharedProjectsDirectory,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const result = resolver.resolveTranscriptPaths([
|
|
125
|
+
{
|
|
126
|
+
sessionName: 'resume',
|
|
127
|
+
sessionId: 'own-id',
|
|
128
|
+
candidateSessionIds: ['own-id', 'own-id'],
|
|
129
|
+
configDir,
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
expect(result.get('resume')).toBe(ownPath);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('resolves a transcript that lives under the shared projects directory', () => {
|
|
137
|
+
const configDir = path.join(configRoot, 'workbench');
|
|
138
|
+
const sharedPath = writeTranscript({
|
|
139
|
+
projectsDirectory: sharedProjectsDirectory,
|
|
140
|
+
cwdSlug: '-home-user',
|
|
141
|
+
sessionId: 'rotated-uuid',
|
|
142
|
+
});
|
|
143
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
144
|
+
sharedProjectsDirectory,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const result = resolver.resolveTranscriptPaths([
|
|
148
|
+
{
|
|
149
|
+
sessionName: 'workbench',
|
|
150
|
+
sessionId: 'launch-uuid',
|
|
151
|
+
candidateSessionIds: ['rotated-uuid', 'launch-uuid'],
|
|
152
|
+
configDir,
|
|
153
|
+
},
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
expect(result.get('workbench')).toBe(sharedPath);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('prefers the most recently modified match across both projects roots', () => {
|
|
60
160
|
const configDir = path.join(configRoot, 'workbench');
|
|
61
161
|
writeTranscript({
|
|
62
|
-
configDir,
|
|
63
|
-
cwdSlug: '-home-user
|
|
64
|
-
sessionId: '
|
|
162
|
+
projectsDirectory: path.join(configDir, 'projects'),
|
|
163
|
+
cwdSlug: '-home-user',
|
|
164
|
+
sessionId: 'rotated-uuid',
|
|
65
165
|
mtimeEpochSeconds: 1700000000,
|
|
66
166
|
});
|
|
67
167
|
const newerPath = writeTranscript({
|
|
68
|
-
|
|
69
|
-
cwdSlug: '-home-user
|
|
70
|
-
sessionId: '
|
|
168
|
+
projectsDirectory: sharedProjectsDirectory,
|
|
169
|
+
cwdSlug: '-home-user',
|
|
170
|
+
sessionId: 'rotated-uuid',
|
|
71
171
|
mtimeEpochSeconds: 1700000500,
|
|
72
172
|
});
|
|
73
|
-
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
173
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
174
|
+
sharedProjectsDirectory,
|
|
175
|
+
);
|
|
74
176
|
|
|
75
177
|
const result = resolver.resolveTranscriptPaths([
|
|
76
|
-
{
|
|
178
|
+
{
|
|
179
|
+
sessionName: 'workbench',
|
|
180
|
+
sessionId: 'launch-uuid',
|
|
181
|
+
candidateSessionIds: ['rotated-uuid', 'launch-uuid'],
|
|
182
|
+
configDir,
|
|
183
|
+
},
|
|
77
184
|
]);
|
|
78
185
|
|
|
79
186
|
expect(result.get('workbench')).toBe(newerPath);
|
|
@@ -83,26 +190,30 @@ describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
|
|
|
83
190
|
const workbenchConfig = path.join(configRoot, 'workbench');
|
|
84
191
|
const controlRoomConfig = path.join(configRoot, 'control-room');
|
|
85
192
|
const workbenchPath = writeTranscript({
|
|
86
|
-
|
|
193
|
+
projectsDirectory: path.join(workbenchConfig, 'projects'),
|
|
87
194
|
cwdSlug: '-home-user',
|
|
88
195
|
sessionId: 'wb-uuid',
|
|
89
196
|
});
|
|
90
197
|
const controlRoomPath = writeTranscript({
|
|
91
|
-
|
|
198
|
+
projectsDirectory: path.join(controlRoomConfig, 'projects'),
|
|
92
199
|
cwdSlug: '-home-user',
|
|
93
200
|
sessionId: 'cr-uuid',
|
|
94
201
|
});
|
|
95
|
-
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
202
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
203
|
+
sharedProjectsDirectory,
|
|
204
|
+
);
|
|
96
205
|
|
|
97
206
|
const result = resolver.resolveTranscriptPaths([
|
|
98
207
|
{
|
|
99
208
|
sessionName: 'workbench',
|
|
100
209
|
sessionId: 'wb-uuid',
|
|
210
|
+
candidateSessionIds: ['wb-uuid'],
|
|
101
211
|
configDir: workbenchConfig,
|
|
102
212
|
},
|
|
103
213
|
{
|
|
104
214
|
sessionName: 'control-room',
|
|
105
215
|
sessionId: 'cr-uuid',
|
|
216
|
+
candidateSessionIds: ['cr-uuid'],
|
|
106
217
|
configDir: controlRoomConfig,
|
|
107
218
|
},
|
|
108
219
|
]);
|
|
@@ -111,34 +222,50 @@ describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
|
|
|
111
222
|
expect(result.get('control-room')).toBe(controlRoomPath);
|
|
112
223
|
});
|
|
113
224
|
|
|
114
|
-
it('omits a session whose transcript file is absent', () => {
|
|
225
|
+
it('omits a session whose transcript file is absent in both roots', () => {
|
|
115
226
|
const configDir = path.join(configRoot, 'workbench');
|
|
116
227
|
fs.mkdirSync(path.join(configDir, 'projects', '-home-user'), {
|
|
117
228
|
recursive: true,
|
|
118
229
|
});
|
|
119
|
-
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
230
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
231
|
+
sharedProjectsDirectory,
|
|
232
|
+
);
|
|
120
233
|
|
|
121
234
|
const result = resolver.resolveTranscriptPaths([
|
|
122
|
-
{
|
|
235
|
+
{
|
|
236
|
+
sessionName: 'workbench',
|
|
237
|
+
sessionId: 'missing-uuid',
|
|
238
|
+
candidateSessionIds: ['missing-uuid'],
|
|
239
|
+
configDir,
|
|
240
|
+
},
|
|
123
241
|
]);
|
|
124
242
|
|
|
125
243
|
expect(result.has('workbench')).toBe(false);
|
|
126
244
|
});
|
|
127
245
|
|
|
128
|
-
it('omits a session
|
|
246
|
+
it('omits a session when neither projects directory exists', () => {
|
|
129
247
|
const configDir = path.join(configRoot, 'workbench');
|
|
130
248
|
fs.mkdirSync(configDir, { recursive: true });
|
|
131
|
-
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
249
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
250
|
+
sharedProjectsDirectory,
|
|
251
|
+
);
|
|
132
252
|
|
|
133
253
|
const result = resolver.resolveTranscriptPaths([
|
|
134
|
-
{
|
|
254
|
+
{
|
|
255
|
+
sessionName: 'workbench',
|
|
256
|
+
sessionId: 'wb-uuid',
|
|
257
|
+
candidateSessionIds: ['wb-uuid'],
|
|
258
|
+
configDir,
|
|
259
|
+
},
|
|
135
260
|
]);
|
|
136
261
|
|
|
137
262
|
expect(result.has('workbench')).toBe(false);
|
|
138
263
|
});
|
|
139
264
|
|
|
140
265
|
it('returns an empty map for an empty session list', () => {
|
|
141
|
-
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
266
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
267
|
+
sharedProjectsDirectory,
|
|
268
|
+
);
|
|
142
269
|
|
|
143
270
|
const result = resolver.resolveTranscriptPaths([]);
|
|
144
271
|
|
|
@@ -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
|
}
|
|
@@ -31,6 +31,17 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
31
31
|
message: { role: 'user', content: 'go ahead' },
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
const toolResultEntry = (timestamp: string): object => ({
|
|
35
|
+
type: 'tool_result',
|
|
36
|
+
timestamp,
|
|
37
|
+
toolUseResult: { stdout: 'done', stderr: '' },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const untimestampedEntry = (): object => ({
|
|
41
|
+
type: 'summary',
|
|
42
|
+
summary: 'no timestamp on this entry',
|
|
43
|
+
});
|
|
44
|
+
|
|
34
45
|
const writeTranscript = (fileName: string, lines: object[]): string => {
|
|
35
46
|
const filePath = path.join(rootDirectory, fileName);
|
|
36
47
|
fs.writeFileSync(
|
|
@@ -41,7 +52,7 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
41
52
|
return filePath;
|
|
42
53
|
};
|
|
43
54
|
|
|
44
|
-
it('returns the latest
|
|
55
|
+
it('returns the latest entry timestamp of any type as the last activity epoch', async () => {
|
|
45
56
|
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
46
57
|
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
47
58
|
userEntry('2026-06-27T10:30:00.000Z'),
|
|
@@ -57,13 +68,13 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
57
68
|
{
|
|
58
69
|
sessionName: 'workbench',
|
|
59
70
|
lastOutputEpochSeconds: Math.floor(
|
|
60
|
-
Date.parse('2026-06-27T10:
|
|
71
|
+
Date.parse('2026-06-27T10:30:00.000Z') / 1000,
|
|
61
72
|
),
|
|
62
73
|
},
|
|
63
74
|
]);
|
|
64
75
|
});
|
|
65
76
|
|
|
66
|
-
it('
|
|
77
|
+
it('advances the last activity time when a later user entry follows an assistant entry', async () => {
|
|
67
78
|
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
68
79
|
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
69
80
|
userEntry('2026-06-27T11:00:00.000Z'),
|
|
@@ -78,7 +89,28 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
78
89
|
{
|
|
79
90
|
sessionName: 'workbench',
|
|
80
91
|
lastOutputEpochSeconds: Math.floor(
|
|
81
|
-
Date.parse('2026-06-
|
|
92
|
+
Date.parse('2026-06-27T11:00:00.000Z') / 1000,
|
|
93
|
+
),
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('advances the last activity time when a tool_result entry is the latest entry', async () => {
|
|
99
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
100
|
+
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
101
|
+
toolResultEntry('2026-06-27T10:45:00.000Z'),
|
|
102
|
+
]);
|
|
103
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
104
|
+
|
|
105
|
+
const result = await repository.listSessionOutputActivities(
|
|
106
|
+
new Map([['workbench', transcriptPath]]),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(result).toEqual([
|
|
110
|
+
{
|
|
111
|
+
sessionName: 'workbench',
|
|
112
|
+
lastOutputEpochSeconds: Math.floor(
|
|
113
|
+
Date.parse('2026-06-27T10:45:00.000Z') / 1000,
|
|
82
114
|
),
|
|
83
115
|
},
|
|
84
116
|
]);
|
|
@@ -107,7 +139,7 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
107
139
|
]);
|
|
108
140
|
});
|
|
109
141
|
|
|
110
|
-
it('
|
|
142
|
+
it('resolves a transcript whose only entry is a non-assistant entry', async () => {
|
|
111
143
|
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
112
144
|
userEntry('2026-06-27T10:00:00.000Z'),
|
|
113
145
|
]);
|
|
@@ -117,6 +149,26 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
117
149
|
new Map([['workbench', transcriptPath]]),
|
|
118
150
|
);
|
|
119
151
|
|
|
152
|
+
expect(result).toEqual([
|
|
153
|
+
{
|
|
154
|
+
sessionName: 'workbench',
|
|
155
|
+
lastOutputEpochSeconds: Math.floor(
|
|
156
|
+
Date.parse('2026-06-27T10:00:00.000Z') / 1000,
|
|
157
|
+
),
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('omits sessions whose transcript has no parseable timestamp', async () => {
|
|
163
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
164
|
+
untimestampedEntry(),
|
|
165
|
+
]);
|
|
166
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
167
|
+
|
|
168
|
+
const result = await repository.listSessionOutputActivities(
|
|
169
|
+
new Map([['workbench', transcriptPath]]),
|
|
170
|
+
);
|
|
171
|
+
|
|
120
172
|
expect(result).toEqual([]);
|
|
121
173
|
});
|
|
122
174
|
|
|
@@ -22,11 +22,14 @@ const parseEpochMilliseconds = (timestamp: string | null): number | null => {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* Reads the last main-session
|
|
25
|
+
* Reads the last main-session activity time for each live session from its
|
|
26
26
|
* already-resolved transcript path. Idle time is computed from the timestamp of
|
|
27
|
-
* the latest
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* the latest entry of any kind (assistant text, owner replies, tool results, or
|
|
28
|
+
* any other entry type) rather than from the transcript file modification time.
|
|
29
|
+
* Because a session that is actively running tool calls keeps appending entries
|
|
30
|
+
* such as `user` and `tool_result` even while it emits no assistant text, every
|
|
31
|
+
* entry with a parseable timestamp counts as activity, so a working session is
|
|
32
|
+
* not mistaken for a silent one.
|
|
30
33
|
*/
|
|
31
34
|
export class FileSystemSessionOutputActivityRepository implements SessionOutputActivityRepository {
|
|
32
35
|
listSessionOutputActivities = async (
|
|
@@ -35,7 +38,7 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
|
|
|
35
38
|
const activities: LiveSessionOutputActivity[] = [];
|
|
36
39
|
for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
|
|
37
40
|
const lastOutputEpochSeconds =
|
|
38
|
-
this.
|
|
41
|
+
this.readLastActivityEpochSeconds(transcriptPath);
|
|
39
42
|
if (lastOutputEpochSeconds !== null) {
|
|
40
43
|
activities.push({ sessionName, lastOutputEpochSeconds });
|
|
41
44
|
}
|
|
@@ -43,7 +46,7 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
|
|
|
43
46
|
return activities;
|
|
44
47
|
};
|
|
45
48
|
|
|
46
|
-
private
|
|
49
|
+
private readLastActivityEpochSeconds = (
|
|
47
50
|
transcriptPath: string,
|
|
48
51
|
): number | null => {
|
|
49
52
|
let content: string;
|
|
@@ -52,7 +55,7 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
|
|
|
52
55
|
} catch {
|
|
53
56
|
return null;
|
|
54
57
|
}
|
|
55
|
-
let
|
|
58
|
+
let lastActivityEpochMs: number | null = null;
|
|
56
59
|
for (const line of content.split('\n')) {
|
|
57
60
|
const trimmed = line.trim();
|
|
58
61
|
if (trimmed.length === 0) {
|
|
@@ -67,20 +70,17 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
|
|
|
67
70
|
if (!isRecord(parsed)) {
|
|
68
71
|
continue;
|
|
69
72
|
}
|
|
70
|
-
if (readString(parsed, 'type') !== 'assistant') {
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
73
|
const epochMs = parseEpochMilliseconds(readString(parsed, 'timestamp'));
|
|
74
74
|
if (epochMs === null) {
|
|
75
75
|
continue;
|
|
76
76
|
}
|
|
77
|
-
if (
|
|
78
|
-
|
|
77
|
+
if (lastActivityEpochMs === null || epochMs > lastActivityEpochMs) {
|
|
78
|
+
lastActivityEpochMs = epochMs;
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
-
if (
|
|
81
|
+
if (lastActivityEpochMs === null) {
|
|
82
82
|
return null;
|
|
83
83
|
}
|
|
84
|
-
return Math.floor(
|
|
84
|
+
return Math.floor(lastActivityEpochMs / 1000);
|
|
85
85
|
};
|
|
86
86
|
}
|
|
@@ -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
|
+
}
|