github-issue-tower-defence-management 1.110.0 → 1.111.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +8 -7
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +9 -21
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +10 -15
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
- package/bin/adapter/proxy/RateLimitCache.js +8 -0
- package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
- package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js +104 -0
- package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js.map +1 -0
- package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +55 -19
- package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -1
- package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js +75 -0
- package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js.map +1 -0
- package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js +1 -1
- package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js.map +1 -1
- package/bin/adapter/repositories/ProcFsProcessEnvironReader.js +73 -0
- package/bin/adapter/repositories/ProcFsProcessEnvironReader.js.map +1 -0
- package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +8 -16
- package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
- package/bin/domain/entities/InteractiveLiveSession.js +3 -0
- package/bin/domain/entities/InteractiveLiveSession.js.map +1 -0
- package/bin/domain/entities/LiveSessionProcessSnapshot.js +3 -0
- package/bin/domain/entities/LiveSessionProcessSnapshot.js.map +1 -0
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +20 -33
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
- package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js +89 -0
- package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/ProcessEnvironReader.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/ProcessEnvironReader.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +5 -15
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +96 -204
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +19 -40
- package/src/adapter/proxy/RateLimitCache.test.ts +63 -0
- package/src/adapter/proxy/RateLimitCache.ts +11 -0
- package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.test.ts +147 -0
- package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.ts +75 -0
- package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +82 -44
- package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +62 -24
- package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +138 -0
- package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.ts +93 -0
- package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.test.ts +6 -4
- package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.ts +1 -1
- package/src/adapter/repositories/ProcFsProcessEnvironReader.test.ts +60 -0
- package/src/adapter/repositories/ProcFsProcessEnvironReader.ts +38 -0
- package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +61 -81
- package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +8 -25
- package/src/domain/entities/InteractiveLiveSession.ts +5 -0
- package/src/domain/entities/LiveSessionProcessSnapshot.ts +17 -0
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +125 -349
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +33 -55
- package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.test.ts +220 -0
- package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.ts +105 -0
- package/src/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.ts +7 -0
- package/src/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.ts +5 -0
- package/src/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.ts +1 -1
- package/src/domain/usecases/adapter-interfaces/ProcessEnvironReader.ts +3 -0
- package/src/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.ts +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -7
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
- package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
- package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts +18 -0
- package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts.map +1 -0
- package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts +9 -5
- package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -1
- package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts +21 -0
- package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts.map +1 -0
- package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts +1 -1
- package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts.map +1 -1
- package/types/adapter/repositories/ProcFsProcessEnvironReader.d.ts +12 -0
- package/types/adapter/repositories/ProcFsProcessEnvironReader.d.ts.map +1 -0
- package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +2 -4
- package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
- package/types/domain/entities/InteractiveLiveSession.d.ts +6 -0
- package/types/domain/entities/InteractiveLiveSession.d.ts.map +1 -0
- package/types/domain/entities/LiveSessionProcessSnapshot.d.ts +16 -0
- package/types/domain/entities/LiveSessionProcessSnapshot.d.ts.map +1 -0
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +6 -11
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts +23 -0
- package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.d.ts +5 -0
- package/types/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.d.ts +5 -0
- package/types/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts +1 -1
- package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/ProcessEnvironReader.d.ts +4 -0
- package/types/domain/usecases/adapter-interfaces/ProcessEnvironReader.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts +1 -1
- package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts.map +1 -1
|
@@ -80,6 +80,16 @@ const readModelWeeklyLimits = (
|
|
|
80
80
|
return result;
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
+
const readSubscriptionDisabledEpoch = (
|
|
84
|
+
payload: Record<string, unknown>,
|
|
85
|
+
): { subscriptionDisabledEpoch: number } | Record<string, never> => {
|
|
86
|
+
const stored = payload.subscriptionDisabledEpoch;
|
|
87
|
+
if (typeof stored === 'number') {
|
|
88
|
+
return { subscriptionDisabledEpoch: stored };
|
|
89
|
+
}
|
|
90
|
+
return {};
|
|
91
|
+
};
|
|
92
|
+
|
|
83
93
|
const cooldownEndFromRetryAfter = (
|
|
84
94
|
retryAfterSeconds: number | null,
|
|
85
95
|
nowEpochSeconds: number,
|
|
@@ -141,6 +151,7 @@ export const writeRateLimit = (
|
|
|
141
151
|
}
|
|
142
152
|
const existing = readPayload(filePath);
|
|
143
153
|
const payload = {
|
|
154
|
+
...readSubscriptionDisabledEpoch(existing),
|
|
144
155
|
ts: Date.now() / 1000,
|
|
145
156
|
headers: rateLimitHeaders,
|
|
146
157
|
modelWeeklyLimits: readModelWeeklyLimits(existing),
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { FileSystemInteractiveLiveSessionTranscriptResolver } from './FileSystemInteractiveLiveSessionTranscriptResolver';
|
|
5
|
+
|
|
6
|
+
describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
|
|
7
|
+
let configRoot: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
configRoot = fs.mkdtempSync(
|
|
11
|
+
path.join(os.tmpdir(), 'interactive-transcript-'),
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
fs.rmSync(configRoot, { force: true, recursive: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const writeTranscript = (params: {
|
|
20
|
+
configDir: string;
|
|
21
|
+
cwdSlug: string;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
mtimeEpochSeconds?: number;
|
|
24
|
+
}): string => {
|
|
25
|
+
const projectDirectory = path.join(
|
|
26
|
+
params.configDir,
|
|
27
|
+
'projects',
|
|
28
|
+
params.cwdSlug,
|
|
29
|
+
);
|
|
30
|
+
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
31
|
+
const filePath = path.join(projectDirectory, `${params.sessionId}.jsonl`);
|
|
32
|
+
fs.writeFileSync(filePath, '{"type":"custom-title"}', 'utf8');
|
|
33
|
+
if (params.mtimeEpochSeconds !== undefined) {
|
|
34
|
+
fs.utimesSync(
|
|
35
|
+
filePath,
|
|
36
|
+
params.mtimeEpochSeconds,
|
|
37
|
+
params.mtimeEpochSeconds,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return filePath;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
it('resolves a transcript by config dir and session id for a plain-named session', () => {
|
|
44
|
+
const configDir = path.join(configRoot, 'workbench');
|
|
45
|
+
const filePath = writeTranscript({
|
|
46
|
+
configDir,
|
|
47
|
+
cwdSlug: '-home-user',
|
|
48
|
+
sessionId: 'wb-uuid',
|
|
49
|
+
});
|
|
50
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
|
|
51
|
+
|
|
52
|
+
const result = resolver.resolveTranscriptPaths([
|
|
53
|
+
{ sessionName: 'workbench', sessionId: 'wb-uuid', configDir },
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
expect(result.get('workbench')).toBe(filePath);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('chooses the most recently modified match across project directories', () => {
|
|
60
|
+
const configDir = path.join(configRoot, 'workbench');
|
|
61
|
+
writeTranscript({
|
|
62
|
+
configDir,
|
|
63
|
+
cwdSlug: '-home-user-old',
|
|
64
|
+
sessionId: 'wb-uuid',
|
|
65
|
+
mtimeEpochSeconds: 1700000000,
|
|
66
|
+
});
|
|
67
|
+
const newerPath = writeTranscript({
|
|
68
|
+
configDir,
|
|
69
|
+
cwdSlug: '-home-user-new',
|
|
70
|
+
sessionId: 'wb-uuid',
|
|
71
|
+
mtimeEpochSeconds: 1700000500,
|
|
72
|
+
});
|
|
73
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
|
|
74
|
+
|
|
75
|
+
const result = resolver.resolveTranscriptPaths([
|
|
76
|
+
{ sessionName: 'workbench', sessionId: 'wb-uuid', configDir },
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
expect(result.get('workbench')).toBe(newerPath);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('resolves several sessions in one call', () => {
|
|
83
|
+
const workbenchConfig = path.join(configRoot, 'workbench');
|
|
84
|
+
const controlRoomConfig = path.join(configRoot, 'control-room');
|
|
85
|
+
const workbenchPath = writeTranscript({
|
|
86
|
+
configDir: workbenchConfig,
|
|
87
|
+
cwdSlug: '-home-user',
|
|
88
|
+
sessionId: 'wb-uuid',
|
|
89
|
+
});
|
|
90
|
+
const controlRoomPath = writeTranscript({
|
|
91
|
+
configDir: controlRoomConfig,
|
|
92
|
+
cwdSlug: '-home-user',
|
|
93
|
+
sessionId: 'cr-uuid',
|
|
94
|
+
});
|
|
95
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
|
|
96
|
+
|
|
97
|
+
const result = resolver.resolveTranscriptPaths([
|
|
98
|
+
{
|
|
99
|
+
sessionName: 'workbench',
|
|
100
|
+
sessionId: 'wb-uuid',
|
|
101
|
+
configDir: workbenchConfig,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
sessionName: 'control-room',
|
|
105
|
+
sessionId: 'cr-uuid',
|
|
106
|
+
configDir: controlRoomConfig,
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
expect(result.get('workbench')).toBe(workbenchPath);
|
|
111
|
+
expect(result.get('control-room')).toBe(controlRoomPath);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('omits a session whose transcript file is absent', () => {
|
|
115
|
+
const configDir = path.join(configRoot, 'workbench');
|
|
116
|
+
fs.mkdirSync(path.join(configDir, 'projects', '-home-user'), {
|
|
117
|
+
recursive: true,
|
|
118
|
+
});
|
|
119
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
|
|
120
|
+
|
|
121
|
+
const result = resolver.resolveTranscriptPaths([
|
|
122
|
+
{ sessionName: 'workbench', sessionId: 'missing-uuid', configDir },
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
expect(result.has('workbench')).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('omits a session whose config dir has no projects directory', () => {
|
|
129
|
+
const configDir = path.join(configRoot, 'workbench');
|
|
130
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
131
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
|
|
132
|
+
|
|
133
|
+
const result = resolver.resolveTranscriptPaths([
|
|
134
|
+
{ sessionName: 'workbench', sessionId: 'wb-uuid', configDir },
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
expect(result.has('workbench')).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('returns an empty map for an empty session list', () => {
|
|
141
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
|
|
142
|
+
|
|
143
|
+
const result = resolver.resolveTranscriptPaths([]);
|
|
144
|
+
|
|
145
|
+
expect(result.size).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { InteractiveLiveSession } from '../../domain/entities/InteractiveLiveSession';
|
|
4
|
+
import { InteractiveLiveSessionTranscriptResolver } from '../../domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver';
|
|
5
|
+
|
|
6
|
+
const modifiedEpochMs = (filePath: string): number | null => {
|
|
7
|
+
try {
|
|
8
|
+
const stats = fs.statSync(filePath);
|
|
9
|
+
return stats.isFile() ? stats.mtimeMs : null;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolves the real transcript path of an interactive Claude Code session from
|
|
17
|
+
* the session id and config directory taken from the session's process
|
|
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
|
+
*/
|
|
26
|
+
export class FileSystemInteractiveLiveSessionTranscriptResolver implements InteractiveLiveSessionTranscriptResolver {
|
|
27
|
+
resolveTranscriptPaths = (
|
|
28
|
+
sessions: InteractiveLiveSession[],
|
|
29
|
+
): Map<string, string> => {
|
|
30
|
+
const resolved = new Map<string, string>();
|
|
31
|
+
for (const session of sessions) {
|
|
32
|
+
const transcriptPath = this.resolveTranscriptPath(session);
|
|
33
|
+
if (transcriptPath !== null) {
|
|
34
|
+
resolved.set(session.sessionName, transcriptPath);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return resolved;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
private resolveTranscriptPath = (
|
|
41
|
+
session: InteractiveLiveSession,
|
|
42
|
+
): string | null => {
|
|
43
|
+
const projectsDirectory = path.join(session.configDir, 'projects');
|
|
44
|
+
let projectEntries: fs.Dirent[];
|
|
45
|
+
try {
|
|
46
|
+
projectEntries = fs.readdirSync(projectsDirectory, {
|
|
47
|
+
withFileTypes: true,
|
|
48
|
+
});
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const fileName = `${session.sessionId}.jsonl`;
|
|
53
|
+
let latestPath: string | null = null;
|
|
54
|
+
let latestEpochMs = -Infinity;
|
|
55
|
+
for (const projectEntry of projectEntries) {
|
|
56
|
+
if (!projectEntry.isDirectory()) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const candidate = path.join(
|
|
60
|
+
projectsDirectory,
|
|
61
|
+
projectEntry.name,
|
|
62
|
+
fileName,
|
|
63
|
+
);
|
|
64
|
+
const epochMs = modifiedEpochMs(candidate);
|
|
65
|
+
if (epochMs === null) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (epochMs > latestEpochMs) {
|
|
69
|
+
latestEpochMs = epochMs;
|
|
70
|
+
latestPath = candidate;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return latestPath;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -16,76 +16,114 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
16
16
|
fs.rmSync(rootDirectory, { force: true, recursive: true });
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
const assistantEntry = (timestamp: string): object => ({
|
|
20
|
+
type: 'assistant',
|
|
21
|
+
timestamp,
|
|
22
|
+
message: {
|
|
23
|
+
role: 'assistant',
|
|
24
|
+
content: [{ type: 'text', text: 'progress update' }],
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const userEntry = (timestamp: string): object => ({
|
|
29
|
+
type: 'user',
|
|
30
|
+
timestamp,
|
|
31
|
+
message: { role: 'user', content: 'go ahead' },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const writeTranscript = (fileName: string, lines: object[]): string => {
|
|
24
35
|
const filePath = path.join(rootDirectory, fileName);
|
|
25
|
-
fs.writeFileSync(
|
|
26
|
-
|
|
36
|
+
fs.writeFileSync(
|
|
37
|
+
filePath,
|
|
38
|
+
lines.map((line) => JSON.stringify(line)).join('\n'),
|
|
39
|
+
'utf8',
|
|
40
|
+
);
|
|
41
|
+
return filePath;
|
|
27
42
|
};
|
|
28
43
|
|
|
29
|
-
it('returns the latest
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
);
|
|
44
|
+
it('returns the latest assistant entry timestamp as the last output epoch', async () => {
|
|
45
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
46
|
+
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
47
|
+
userEntry('2026-06-27T10:30:00.000Z'),
|
|
48
|
+
assistantEntry('2026-06-27T10:05:00.000Z'),
|
|
49
|
+
]);
|
|
50
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
35
51
|
|
|
36
|
-
const result = await repository.listSessionOutputActivities(
|
|
52
|
+
const result = await repository.listSessionOutputActivities(
|
|
53
|
+
new Map([['workbench', transcriptPath]]),
|
|
54
|
+
);
|
|
37
55
|
|
|
38
56
|
expect(result).toEqual([
|
|
39
|
-
{
|
|
57
|
+
{
|
|
58
|
+
sessionName: 'workbench',
|
|
59
|
+
lastOutputEpochSeconds: Math.floor(
|
|
60
|
+
Date.parse('2026-06-27T10:05:00.000Z') / 1000,
|
|
61
|
+
),
|
|
62
|
+
},
|
|
40
63
|
]);
|
|
41
64
|
});
|
|
42
65
|
|
|
43
|
-
it('
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const repository = new FileSystemSessionOutputActivityRepository(
|
|
48
|
-
rootDirectory,
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
const result = await repository.listSessionOutputActivities([
|
|
52
|
-
presentSessionName,
|
|
53
|
-
missingSessionName,
|
|
66
|
+
it('ignores user entries when computing the last output epoch', async () => {
|
|
67
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
68
|
+
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
69
|
+
userEntry('2026-06-27T11:00:00.000Z'),
|
|
54
70
|
]);
|
|
71
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
72
|
+
|
|
73
|
+
const result = await repository.listSessionOutputActivities(
|
|
74
|
+
new Map([['workbench', transcriptPath]]),
|
|
75
|
+
);
|
|
55
76
|
|
|
56
77
|
expect(result).toEqual([
|
|
57
|
-
{
|
|
78
|
+
{
|
|
79
|
+
sessionName: 'workbench',
|
|
80
|
+
lastOutputEpochSeconds: Math.floor(
|
|
81
|
+
Date.parse('2026-06-27T10:00:00.000Z') / 1000,
|
|
82
|
+
),
|
|
83
|
+
},
|
|
58
84
|
]);
|
|
59
85
|
});
|
|
60
86
|
|
|
61
|
-
it('
|
|
62
|
-
const
|
|
63
|
-
|
|
87
|
+
it('omits sessions whose transcript file is missing', async () => {
|
|
88
|
+
const presentPath = writeTranscript('present.jsonl', [
|
|
89
|
+
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
90
|
+
]);
|
|
91
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
92
|
+
|
|
93
|
+
const result = await repository.listSessionOutputActivities(
|
|
94
|
+
new Map([
|
|
95
|
+
['present', presentPath],
|
|
96
|
+
['missing', path.join(rootDirectory, 'missing.jsonl')],
|
|
97
|
+
]),
|
|
64
98
|
);
|
|
65
99
|
|
|
66
|
-
|
|
67
|
-
|
|
100
|
+
expect(result).toEqual([
|
|
101
|
+
{
|
|
102
|
+
sessionName: 'present',
|
|
103
|
+
lastOutputEpochSeconds: Math.floor(
|
|
104
|
+
Date.parse('2026-06-27T10:00:00.000Z') / 1000,
|
|
105
|
+
),
|
|
106
|
+
},
|
|
68
107
|
]);
|
|
69
|
-
|
|
70
|
-
expect(result).toEqual([]);
|
|
71
108
|
});
|
|
72
109
|
|
|
73
|
-
it('
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
const result = await repository.listSessionOutputActivities([
|
|
77
|
-
'https_//github_com/owner/repo/issues/9',
|
|
110
|
+
it('omits sessions whose transcript has no assistant entry', async () => {
|
|
111
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
112
|
+
userEntry('2026-06-27T10:00:00.000Z'),
|
|
78
113
|
]);
|
|
114
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
115
|
+
|
|
116
|
+
const result = await repository.listSessionOutputActivities(
|
|
117
|
+
new Map([['workbench', transcriptPath]]),
|
|
118
|
+
);
|
|
79
119
|
|
|
80
120
|
expect(result).toEqual([]);
|
|
81
121
|
});
|
|
82
122
|
|
|
83
|
-
it('returns an empty list when no
|
|
84
|
-
const repository = new FileSystemSessionOutputActivityRepository(
|
|
85
|
-
rootDirectory,
|
|
86
|
-
);
|
|
123
|
+
it('returns an empty list when no sessions are requested', async () => {
|
|
124
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
87
125
|
|
|
88
|
-
const result = await repository.listSessionOutputActivities(
|
|
126
|
+
const result = await repository.listSessionOutputActivities(new Map());
|
|
89
127
|
|
|
90
128
|
expect(result).toEqual([]);
|
|
91
129
|
});
|
|
@@ -1,21 +1,41 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
|
-
import * as path from 'path';
|
|
3
2
|
import { LiveSessionOutputActivity } from '../../domain/entities/LiveSessionOutputActivity';
|
|
4
3
|
import { SessionOutputActivityRepository } from '../../domain/usecases/adapter-interfaces/SessionOutputActivityRepository';
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
6
|
+
typeof value === 'object' && value !== null;
|
|
7
|
+
|
|
8
|
+
const readString = (
|
|
9
|
+
value: Record<string, unknown>,
|
|
10
|
+
key: string,
|
|
11
|
+
): string | null => {
|
|
12
|
+
const candidate = value[key];
|
|
13
|
+
return typeof candidate === 'string' ? candidate : null;
|
|
14
|
+
};
|
|
8
15
|
|
|
16
|
+
const parseEpochMilliseconds = (timestamp: string | null): number | null => {
|
|
17
|
+
if (timestamp === null) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const parsed = Date.parse(timestamp);
|
|
21
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reads the last main-session output time for each live session from its
|
|
26
|
+
* already-resolved transcript path. Idle time is computed from the timestamp of
|
|
27
|
+
* the latest `assistant` entry rather than from the transcript file modification
|
|
28
|
+
* time, so a transcript touched only by tool results or owner replies still
|
|
29
|
+
* counts as silent.
|
|
30
|
+
*/
|
|
31
|
+
export class FileSystemSessionOutputActivityRepository implements SessionOutputActivityRepository {
|
|
9
32
|
listSessionOutputActivities = async (
|
|
10
|
-
|
|
33
|
+
transcriptPathBySessionName: Map<string, string>,
|
|
11
34
|
): Promise<LiveSessionOutputActivity[]> => {
|
|
12
|
-
if (this.rootDirectory === null) {
|
|
13
|
-
return [];
|
|
14
|
-
}
|
|
15
35
|
const activities: LiveSessionOutputActivity[] = [];
|
|
16
|
-
for (const sessionName of
|
|
36
|
+
for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
|
|
17
37
|
const lastOutputEpochSeconds =
|
|
18
|
-
this.
|
|
38
|
+
this.readLastAssistantOutputEpochSeconds(transcriptPath);
|
|
19
39
|
if (lastOutputEpochSeconds !== null) {
|
|
20
40
|
activities.push({ sessionName, lastOutputEpochSeconds });
|
|
21
41
|
}
|
|
@@ -23,26 +43,44 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
|
|
|
23
43
|
return activities;
|
|
24
44
|
};
|
|
25
45
|
|
|
26
|
-
private
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const filePath = path.join(
|
|
31
|
-
this.rootDirectory,
|
|
32
|
-
this.toOutputFileName(sessionName),
|
|
33
|
-
);
|
|
34
|
-
let stats: fs.Stats;
|
|
46
|
+
private readLastAssistantOutputEpochSeconds = (
|
|
47
|
+
transcriptPath: string,
|
|
48
|
+
): number | null => {
|
|
49
|
+
let content: string;
|
|
35
50
|
try {
|
|
36
|
-
|
|
51
|
+
content = fs.readFileSync(transcriptPath, 'utf8');
|
|
37
52
|
} catch {
|
|
38
53
|
return null;
|
|
39
54
|
}
|
|
40
|
-
|
|
55
|
+
let lastAssistantEpochMs: number | null = null;
|
|
56
|
+
for (const line of content.split('\n')) {
|
|
57
|
+
const trimmed = line.trim();
|
|
58
|
+
if (trimmed.length === 0) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
let parsed: unknown;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(trimmed);
|
|
64
|
+
} catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (!isRecord(parsed)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (readString(parsed, 'type') !== 'assistant') {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const epochMs = parseEpochMilliseconds(readString(parsed, 'timestamp'));
|
|
74
|
+
if (epochMs === null) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (lastAssistantEpochMs === null || epochMs > lastAssistantEpochMs) {
|
|
78
|
+
lastAssistantEpochMs = epochMs;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (lastAssistantEpochMs === null) {
|
|
41
82
|
return null;
|
|
42
83
|
}
|
|
43
|
-
return Math.floor(
|
|
84
|
+
return Math.floor(lastAssistantEpochMs / 1000);
|
|
44
85
|
};
|
|
45
|
-
|
|
46
|
-
private toOutputFileName = (sessionName: string): string =>
|
|
47
|
-
sessionName.replace(/\//g, '_');
|
|
48
86
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { LocalProcessLiveSessionProcessSnapshotProvider } from './LocalProcessLiveSessionProcessSnapshotProvider';
|
|
2
|
+
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
3
|
+
import { ProcessEnvironReader } from '../../domain/usecases/adapter-interfaces/ProcessEnvironReader';
|
|
4
|
+
|
|
5
|
+
describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
|
|
6
|
+
const makeRunner = (
|
|
7
|
+
handlers: Record<
|
|
8
|
+
string,
|
|
9
|
+
{ stdout: string; stderr?: string; exitCode?: number }
|
|
10
|
+
>,
|
|
11
|
+
): LocalCommandRunner => ({
|
|
12
|
+
runCommand: jest.fn(async (program: string, args: string[]) => {
|
|
13
|
+
const key = `${program} ${args.join(' ')}`;
|
|
14
|
+
const handler = handlers[key];
|
|
15
|
+
if (handler === undefined) {
|
|
16
|
+
return { stdout: '', stderr: '', exitCode: 1 };
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
stdout: handler.stdout,
|
|
20
|
+
stderr: handler.stderr ?? '',
|
|
21
|
+
exitCode: handler.exitCode ?? 0,
|
|
22
|
+
};
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const makeEnvironReader = (
|
|
27
|
+
byPid: Record<number, Record<string, string>>,
|
|
28
|
+
): ProcessEnvironReader => ({
|
|
29
|
+
readEnviron: (pid: number) => byPid[pid] ?? null,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('builds a snapshot of sessions, pane pids, and processes with environment', async () => {
|
|
33
|
+
const runner = makeRunner({
|
|
34
|
+
'tmux list-sessions -F #{session_name}': {
|
|
35
|
+
stdout: 'workbench\nhttps_//github_com/owner/repo/issues/9\n',
|
|
36
|
+
},
|
|
37
|
+
'tmux list-panes -t workbench -F #{pane_pid}': { stdout: '200\n' },
|
|
38
|
+
'tmux list-panes -t https_//github_com/owner/repo/issues/9 -F #{pane_pid}':
|
|
39
|
+
{ stdout: '100\n' },
|
|
40
|
+
'ps -eo pid=,ppid=,args=': {
|
|
41
|
+
stdout: [
|
|
42
|
+
' 100 1 shell',
|
|
43
|
+
' 101 100 claude --name issues-9',
|
|
44
|
+
' 200 1 shell',
|
|
45
|
+
' 201 200 claude --name workbench',
|
|
46
|
+
].join('\n'),
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const environReader = makeEnvironReader({
|
|
50
|
+
101: {
|
|
51
|
+
CLAUDE_CODE_SESSION_ID: 'issue-uuid',
|
|
52
|
+
CLAUDE_CONFIG_DIR: '/config/issues-9',
|
|
53
|
+
},
|
|
54
|
+
201: {
|
|
55
|
+
CLAUDE_CODE_SESSION_ID: 'wb-uuid',
|
|
56
|
+
CLAUDE_CONFIG_DIR: '/config/workbench',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const provider = new LocalProcessLiveSessionProcessSnapshotProvider(
|
|
60
|
+
runner,
|
|
61
|
+
environReader,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const snapshot = await provider.getSnapshot();
|
|
65
|
+
|
|
66
|
+
expect(snapshot.sessions).toEqual([
|
|
67
|
+
{ sessionName: 'workbench', panePids: [200] },
|
|
68
|
+
{
|
|
69
|
+
sessionName: 'https_//github_com/owner/repo/issues/9',
|
|
70
|
+
panePids: [100],
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
expect(snapshot.processes).toContainEqual({
|
|
74
|
+
pid: 201,
|
|
75
|
+
ppid: 200,
|
|
76
|
+
commandLine: 'claude --name workbench',
|
|
77
|
+
sessionId: 'wb-uuid',
|
|
78
|
+
configDir: '/config/workbench',
|
|
79
|
+
});
|
|
80
|
+
expect(snapshot.processes).toContainEqual({
|
|
81
|
+
pid: 100,
|
|
82
|
+
ppid: 1,
|
|
83
|
+
commandLine: 'shell',
|
|
84
|
+
sessionId: null,
|
|
85
|
+
configDir: null,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns an empty snapshot when tmux reports no sessions', async () => {
|
|
90
|
+
const runner = makeRunner({
|
|
91
|
+
'tmux list-sessions -F #{session_name}': { stdout: '' },
|
|
92
|
+
'ps -eo pid=,ppid=,args=': { stdout: '' },
|
|
93
|
+
});
|
|
94
|
+
const provider = new LocalProcessLiveSessionProcessSnapshotProvider(
|
|
95
|
+
runner,
|
|
96
|
+
makeEnvironReader({}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const snapshot = await provider.getSnapshot();
|
|
100
|
+
|
|
101
|
+
expect(snapshot.sessions).toEqual([]);
|
|
102
|
+
expect(snapshot.processes).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('skips malformed ps lines', async () => {
|
|
106
|
+
const runner = makeRunner({
|
|
107
|
+
'tmux list-sessions -F #{session_name}': { stdout: 'workbench\n' },
|
|
108
|
+
'tmux list-panes -t workbench -F #{pane_pid}': { stdout: '200\n' },
|
|
109
|
+
'ps -eo pid=,ppid=,args=': {
|
|
110
|
+
stdout: [
|
|
111
|
+
'garbage line',
|
|
112
|
+
' 201 200 claude --name workbench',
|
|
113
|
+
].join('\n'),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
const provider = new LocalProcessLiveSessionProcessSnapshotProvider(
|
|
117
|
+
runner,
|
|
118
|
+
makeEnvironReader({
|
|
119
|
+
201: {
|
|
120
|
+
CLAUDE_CODE_SESSION_ID: 'wb-uuid',
|
|
121
|
+
CLAUDE_CONFIG_DIR: '/config/workbench',
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const snapshot = await provider.getSnapshot();
|
|
127
|
+
|
|
128
|
+
expect(snapshot.processes).toEqual([
|
|
129
|
+
{
|
|
130
|
+
pid: 201,
|
|
131
|
+
ppid: 200,
|
|
132
|
+
commandLine: 'claude --name workbench',
|
|
133
|
+
sessionId: 'wb-uuid',
|
|
134
|
+
configDir: '/config/workbench',
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
});
|