github-issue-tower-defence-management 1.104.4 → 1.106.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 +20 -0
- package/README.md +8 -7
- package/bin/adapter/entry-points/cli/index.js +21 -13
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/{consoleServer.js → webServer.js} +11 -11
- package/bin/adapter/entry-points/console/webServer.js.map +1 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +54 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/dashboardRowWriter.js +34 -0
- package/bin/adapter/entry-points/handlers/dashboardRowWriter.js.map +1 -0
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js +60 -0
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -0
- package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js +18 -0
- package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js.map +1 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +98 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -0
- package/bin/adapter/proxy/RateLimitCache.js +3 -0
- package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js +22 -0
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
- package/bin/adapter/repositories/ProcHostMetricsRepository.js +136 -0
- package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -0
- package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js +131 -0
- package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js.map +1 -0
- package/bin/domain/entities/LiveTmuxSession.js +3 -0
- package/bin/domain/entities/LiveTmuxSession.js.map +1 -0
- package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js +58 -0
- package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js.map +1 -0
- package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js +38 -0
- package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js.map +1 -0
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +115 -0
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +74 -21
- package/src/adapter/entry-points/cli/index.ts +146 -136
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -2
- package/src/adapter/entry-points/console/{consoleServer.test.ts → webServer.test.ts} +32 -32
- package/src/adapter/entry-points/console/{consoleServer.ts → webServer.ts} +15 -17
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +69 -0
- package/src/adapter/entry-points/handlers/dashboardRowWriter.test.ts +111 -0
- package/src/adapter/entry-points/handlers/dashboardRowWriter.ts +51 -0
- package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +100 -0
- package/src/adapter/entry-points/handlers/machineStatusWriter.ts +79 -0
- package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +170 -0
- package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.ts +40 -0
- package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +176 -0
- package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +139 -0
- package/src/adapter/proxy/RateLimitCache.test.ts +32 -0
- package/src/adapter/proxy/RateLimitCache.ts +6 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +81 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.ts +35 -0
- package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +135 -0
- package/src/adapter/repositories/ProcHostMetricsRepository.ts +136 -0
- package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.test.ts +130 -0
- package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.ts +118 -0
- package/src/domain/entities/LiveTmuxSession.ts +4 -0
- package/src/domain/usecases/StaleTmuxSessionKillUseCase.test.ts +286 -0
- package/src/domain/usecases/StaleTmuxSessionKillUseCase.ts +102 -0
- package/src/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.ts +8 -0
- package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +4 -0
- package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.test.ts +159 -0
- package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.ts +72 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +209 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +195 -0
- package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +6 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/{consoleServer.d.ts → webServer.d.ts} +7 -7
- package/types/adapter/entry-points/console/webServer.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts +15 -0
- package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +16 -0
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts +12 -0
- package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts +21 -0
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -0
- package/types/adapter/proxy/RateLimitCache.d.ts +2 -0
- package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +3 -0
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +23 -0
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -0
- package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts +11 -0
- package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts.map +1 -0
- package/types/domain/entities/LiveTmuxSession.d.ts +5 -0
- package/types/domain/entities/LiveTmuxSession.d.ts.map +1 -0
- package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts +19 -0
- package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts +8 -0
- package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +3 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
- package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts +19 -0
- package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts.map +1 -0
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +53 -0
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -0
- package/bin/adapter/entry-points/console/consoleServer.js.map +0 -1
- package/types/adapter/entry-points/console/consoleServer.d.ts.map +0 -1
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import {
|
|
2
|
+
StaleTmuxSessionKillUseCase,
|
|
3
|
+
DEFAULT_EXCLUDED_STATUS,
|
|
4
|
+
DEFAULT_IDLE_THRESHOLD_SECONDS,
|
|
5
|
+
} from './StaleTmuxSessionKillUseCase';
|
|
6
|
+
import { IssueRepository } from './adapter-interfaces/IssueRepository';
|
|
7
|
+
import { TmuxSessionRepository } from './adapter-interfaces/TmuxSessionRepository';
|
|
8
|
+
import { toTmuxSessionName } from './intmux/InTmuxByHumanSessionReconcileUseCase';
|
|
9
|
+
import { Issue } from '../entities/Issue';
|
|
10
|
+
import { LiveTmuxSession } from '../entities/LiveTmuxSession';
|
|
11
|
+
import { Project } from '../entities/Project';
|
|
12
|
+
import { IN_TMUX_STATUS_NAME } from '../entities/WorkflowStatus';
|
|
13
|
+
|
|
14
|
+
type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
|
|
15
|
+
|
|
16
|
+
const createMockProject = (): Project => ({
|
|
17
|
+
id: 'project-1',
|
|
18
|
+
url: 'https://github.com/users/user/projects/1',
|
|
19
|
+
databaseId: 1,
|
|
20
|
+
name: 'Test Project',
|
|
21
|
+
status: {
|
|
22
|
+
name: 'Status',
|
|
23
|
+
fieldId: 'field-1',
|
|
24
|
+
statuses: [],
|
|
25
|
+
},
|
|
26
|
+
nextActionDate: null,
|
|
27
|
+
nextActionHour: null,
|
|
28
|
+
story: null,
|
|
29
|
+
remainingEstimationMinutes: null,
|
|
30
|
+
dependedIssueUrlSeparatedByComma: null,
|
|
31
|
+
completionDate50PercentConfidence: null,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
let issueCounter = 0;
|
|
35
|
+
const createMockIssue = (overrides: Partial<Issue> = {}): Issue => {
|
|
36
|
+
issueCounter += 1;
|
|
37
|
+
return {
|
|
38
|
+
nameWithOwner: 'user/repo',
|
|
39
|
+
number: issueCounter,
|
|
40
|
+
title: `Test Issue ${issueCounter}`,
|
|
41
|
+
state: 'OPEN',
|
|
42
|
+
status: IN_TMUX_STATUS_NAME,
|
|
43
|
+
story: null,
|
|
44
|
+
nextActionDate: null,
|
|
45
|
+
nextActionHour: null,
|
|
46
|
+
estimationMinutes: null,
|
|
47
|
+
dependedIssueUrls: [],
|
|
48
|
+
completionDate50PercentConfidence: null,
|
|
49
|
+
url: `https://github.com/user/repo/issues/${issueCounter}`,
|
|
50
|
+
assignees: [],
|
|
51
|
+
labels: [],
|
|
52
|
+
org: 'user',
|
|
53
|
+
repo: 'repo',
|
|
54
|
+
body: '',
|
|
55
|
+
itemId: `item-${issueCounter}`,
|
|
56
|
+
isPr: false,
|
|
57
|
+
isInProgress: false,
|
|
58
|
+
isClosed: false,
|
|
59
|
+
createdAt: new Date(),
|
|
60
|
+
author: 'testuser',
|
|
61
|
+
closingIssueReferenceUrls: [],
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
describe('StaleTmuxSessionKillUseCase', () => {
|
|
67
|
+
let useCase: StaleTmuxSessionKillUseCase;
|
|
68
|
+
let mockIssueRepository: Mocked<Pick<IssueRepository, 'getAllOpened'>>;
|
|
69
|
+
let mockTmuxSessionRepository: Mocked<
|
|
70
|
+
Pick<TmuxSessionRepository, 'listLiveSessionsWithActivity' | 'killSession'>
|
|
71
|
+
>;
|
|
72
|
+
let mockProject: Project;
|
|
73
|
+
const now = new Date('2026-06-26T00:00:00Z');
|
|
74
|
+
const nowEpochSeconds = Math.floor(now.getTime() / 1000);
|
|
75
|
+
const allowCacheMinutes = 10;
|
|
76
|
+
|
|
77
|
+
const runParams = (): {
|
|
78
|
+
project: Project;
|
|
79
|
+
allowCacheMinutes: number;
|
|
80
|
+
excludedStatus: string;
|
|
81
|
+
idleThresholdSeconds: number;
|
|
82
|
+
now: Date;
|
|
83
|
+
} => ({
|
|
84
|
+
project: mockProject,
|
|
85
|
+
allowCacheMinutes,
|
|
86
|
+
excludedStatus: DEFAULT_EXCLUDED_STATUS,
|
|
87
|
+
idleThresholdSeconds: DEFAULT_IDLE_THRESHOLD_SECONDS,
|
|
88
|
+
now,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const setLiveSessions = (sessions: LiveTmuxSession[]): void => {
|
|
92
|
+
mockTmuxSessionRepository.listLiveSessionsWithActivity.mockResolvedValue(
|
|
93
|
+
sessions,
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
jest.resetAllMocks();
|
|
99
|
+
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
100
|
+
mockProject = createMockProject();
|
|
101
|
+
mockIssueRepository = {
|
|
102
|
+
getAllOpened: jest.fn().mockResolvedValue([]),
|
|
103
|
+
};
|
|
104
|
+
mockTmuxSessionRepository = {
|
|
105
|
+
listLiveSessionsWithActivity: jest.fn().mockResolvedValue([]),
|
|
106
|
+
killSession: jest.fn().mockResolvedValue(undefined),
|
|
107
|
+
};
|
|
108
|
+
useCase = new StaleTmuxSessionKillUseCase(
|
|
109
|
+
mockIssueRepository,
|
|
110
|
+
mockTmuxSessionRepository,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('exposes the excluded status and idle threshold as named constants', () => {
|
|
115
|
+
expect(DEFAULT_EXCLUDED_STATUS).toBe('In Tmux by human');
|
|
116
|
+
expect(DEFAULT_EXCLUDED_STATUS).toBe(IN_TMUX_STATUS_NAME);
|
|
117
|
+
expect(DEFAULT_IDLE_THRESHOLD_SECONDS).toBe(86400);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('lists live sessions with activity and loads open issues for the project', async () => {
|
|
121
|
+
setLiveSessions([]);
|
|
122
|
+
await useCase.run(runParams());
|
|
123
|
+
expect(
|
|
124
|
+
mockTmuxSessionRepository.listLiveSessionsWithActivity,
|
|
125
|
+
).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(mockIssueRepository.getAllOpened).toHaveBeenCalledWith(
|
|
127
|
+
mockProject,
|
|
128
|
+
allowCacheMinutes,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('kills a session mapping to an open issue whose status is not the excluded status', async () => {
|
|
133
|
+
const issue = createMockIssue({ status: 'In Progress' });
|
|
134
|
+
const sessionName = toTmuxSessionName(issue.url);
|
|
135
|
+
setLiveSessions([{ sessionName, activityEpochSeconds: nowEpochSeconds }]);
|
|
136
|
+
mockIssueRepository.getAllOpened.mockResolvedValue([issue]);
|
|
137
|
+
await useCase.run(runParams());
|
|
138
|
+
expect(mockTmuxSessionRepository.killSession).toHaveBeenCalledWith(
|
|
139
|
+
sessionName,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('kills a session mapping to an open issue whose status is null', async () => {
|
|
144
|
+
const issue = createMockIssue({ status: null });
|
|
145
|
+
const sessionName = toTmuxSessionName(issue.url);
|
|
146
|
+
setLiveSessions([{ sessionName, activityEpochSeconds: nowEpochSeconds }]);
|
|
147
|
+
mockIssueRepository.getAllOpened.mockResolvedValue([issue]);
|
|
148
|
+
await useCase.run(runParams());
|
|
149
|
+
expect(mockTmuxSessionRepository.killSession).toHaveBeenCalledWith(
|
|
150
|
+
sessionName,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('kills an excluded-status session that has a next action date set', async () => {
|
|
155
|
+
const issue = createMockIssue({
|
|
156
|
+
status: DEFAULT_EXCLUDED_STATUS,
|
|
157
|
+
nextActionDate: new Date('2026-06-27T00:00:00Z'),
|
|
158
|
+
});
|
|
159
|
+
const sessionName = toTmuxSessionName(issue.url);
|
|
160
|
+
setLiveSessions([{ sessionName, activityEpochSeconds: nowEpochSeconds }]);
|
|
161
|
+
mockIssueRepository.getAllOpened.mockResolvedValue([issue]);
|
|
162
|
+
await useCase.run(runParams());
|
|
163
|
+
expect(mockTmuxSessionRepository.killSession).toHaveBeenCalledWith(
|
|
164
|
+
sessionName,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('kills an excluded-status session that has a next action hour set', async () => {
|
|
169
|
+
const issue = createMockIssue({
|
|
170
|
+
status: DEFAULT_EXCLUDED_STATUS,
|
|
171
|
+
nextActionHour: 9,
|
|
172
|
+
});
|
|
173
|
+
const sessionName = toTmuxSessionName(issue.url);
|
|
174
|
+
setLiveSessions([{ sessionName, activityEpochSeconds: nowEpochSeconds }]);
|
|
175
|
+
mockIssueRepository.getAllOpened.mockResolvedValue([issue]);
|
|
176
|
+
await useCase.run(runParams());
|
|
177
|
+
expect(mockTmuxSessionRepository.killSession).toHaveBeenCalledWith(
|
|
178
|
+
sessionName,
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('never kills an excluded-status session that has no reactivation trigger', async () => {
|
|
183
|
+
const issue = createMockIssue({
|
|
184
|
+
status: DEFAULT_EXCLUDED_STATUS,
|
|
185
|
+
nextActionDate: null,
|
|
186
|
+
nextActionHour: null,
|
|
187
|
+
});
|
|
188
|
+
const sessionName = toTmuxSessionName(issue.url);
|
|
189
|
+
setLiveSessions([{ sessionName, activityEpochSeconds: nowEpochSeconds }]);
|
|
190
|
+
mockIssueRepository.getAllOpened.mockResolvedValue([issue]);
|
|
191
|
+
await useCase.run(runParams());
|
|
192
|
+
expect(mockTmuxSessionRepository.killSession).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('kills a no-task session that has been idle at least the idle threshold', async () => {
|
|
196
|
+
const sessionName = 'no_task_session';
|
|
197
|
+
const idleActivity = nowEpochSeconds - DEFAULT_IDLE_THRESHOLD_SECONDS;
|
|
198
|
+
setLiveSessions([{ sessionName, activityEpochSeconds: idleActivity }]);
|
|
199
|
+
mockIssueRepository.getAllOpened.mockResolvedValue([]);
|
|
200
|
+
await useCase.run(runParams());
|
|
201
|
+
expect(mockTmuxSessionRepository.killSession).toHaveBeenCalledWith(
|
|
202
|
+
sessionName,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('never kills a no-task session that was active within the idle threshold', async () => {
|
|
207
|
+
const sessionName = 'no_task_session';
|
|
208
|
+
const recentActivity = nowEpochSeconds - DEFAULT_IDLE_THRESHOLD_SECONDS + 1;
|
|
209
|
+
setLiveSessions([{ sessionName, activityEpochSeconds: recentActivity }]);
|
|
210
|
+
mockIssueRepository.getAllOpened.mockResolvedValue([]);
|
|
211
|
+
await useCase.run(runParams());
|
|
212
|
+
expect(mockTmuxSessionRepository.killSession).not.toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('does nothing when there are no live sessions', async () => {
|
|
216
|
+
setLiveSessions([]);
|
|
217
|
+
mockIssueRepository.getAllOpened.mockResolvedValue([]);
|
|
218
|
+
await useCase.run(runParams());
|
|
219
|
+
expect(mockTmuxSessionRepository.killSession).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('maps a session back to its issue using the dot-and-colon-to-underscore convention', async () => {
|
|
223
|
+
const issue = createMockIssue({
|
|
224
|
+
url: 'https://github.com/owner/repo/issues/9',
|
|
225
|
+
status: 'In Progress',
|
|
226
|
+
});
|
|
227
|
+
const sessionName = 'https_//github_com/owner/repo/issues/9';
|
|
228
|
+
expect(toTmuxSessionName(issue.url)).toBe(sessionName);
|
|
229
|
+
setLiveSessions([{ sessionName, activityEpochSeconds: nowEpochSeconds }]);
|
|
230
|
+
mockIssueRepository.getAllOpened.mockResolvedValue([issue]);
|
|
231
|
+
await useCase.run(runParams());
|
|
232
|
+
expect(mockTmuxSessionRepository.killSession).toHaveBeenCalledWith(
|
|
233
|
+
sessionName,
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('enumerates and logs the kill-candidate list before killing', async () => {
|
|
238
|
+
const logSpy = jest.spyOn(console, 'log');
|
|
239
|
+
const killOrder: string[] = [];
|
|
240
|
+
mockTmuxSessionRepository.killSession.mockImplementation(
|
|
241
|
+
async (sessionName: string) => {
|
|
242
|
+
killOrder.push(sessionName);
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
const issue = createMockIssue({ status: 'In Progress' });
|
|
246
|
+
const sessionName = toTmuxSessionName(issue.url);
|
|
247
|
+
setLiveSessions([{ sessionName, activityEpochSeconds: nowEpochSeconds }]);
|
|
248
|
+
mockIssueRepository.getAllOpened.mockResolvedValue([issue]);
|
|
249
|
+
|
|
250
|
+
await useCase.run(runParams());
|
|
251
|
+
|
|
252
|
+
const candidateLogIndex = logSpy.mock.calls.findIndex((call) =>
|
|
253
|
+
String(call[0]).startsWith('Kill candidate:'),
|
|
254
|
+
);
|
|
255
|
+
const killedLogIndex = logSpy.mock.calls.findIndex((call) =>
|
|
256
|
+
String(call[0]).startsWith('Killed tmux session:'),
|
|
257
|
+
);
|
|
258
|
+
expect(candidateLogIndex).toBeGreaterThanOrEqual(0);
|
|
259
|
+
expect(killedLogIndex).toBeGreaterThan(candidateLogIndex);
|
|
260
|
+
expect(killOrder).toEqual([sessionName]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('does not suppress errors raised while killing a session', async () => {
|
|
264
|
+
const issue = createMockIssue({ status: 'In Progress' });
|
|
265
|
+
const sessionName = toTmuxSessionName(issue.url);
|
|
266
|
+
setLiveSessions([{ sessionName, activityEpochSeconds: nowEpochSeconds }]);
|
|
267
|
+
mockIssueRepository.getAllOpened.mockResolvedValue([issue]);
|
|
268
|
+
mockTmuxSessionRepository.killSession.mockRejectedValue(
|
|
269
|
+
new Error('tmux kill failed'),
|
|
270
|
+
);
|
|
271
|
+
await expect(useCase.run(runParams())).rejects.toThrow('tmux kill failed');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('does not kill any session when loading open issues fails', async () => {
|
|
275
|
+
const sessionName = 'no_task_session';
|
|
276
|
+
const idleActivity = nowEpochSeconds - DEFAULT_IDLE_THRESHOLD_SECONDS;
|
|
277
|
+
setLiveSessions([{ sessionName, activityEpochSeconds: idleActivity }]);
|
|
278
|
+
mockIssueRepository.getAllOpened.mockRejectedValue(
|
|
279
|
+
new Error('failed to load issues'),
|
|
280
|
+
);
|
|
281
|
+
await expect(useCase.run(runParams())).rejects.toThrow(
|
|
282
|
+
'failed to load issues',
|
|
283
|
+
);
|
|
284
|
+
expect(mockTmuxSessionRepository.killSession).not.toHaveBeenCalled();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Issue } from '../entities/Issue';
|
|
2
|
+
import { LiveTmuxSession } from '../entities/LiveTmuxSession';
|
|
3
|
+
import { Project } from '../entities/Project';
|
|
4
|
+
import { IN_TMUX_STATUS_NAME } from '../entities/WorkflowStatus';
|
|
5
|
+
import { IssueRepository } from './adapter-interfaces/IssueRepository';
|
|
6
|
+
import { TmuxSessionRepository } from './adapter-interfaces/TmuxSessionRepository';
|
|
7
|
+
import { toTmuxSessionName } from './intmux/InTmuxByHumanSessionReconcileUseCase';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_EXCLUDED_STATUS = IN_TMUX_STATUS_NAME;
|
|
10
|
+
export const DEFAULT_IDLE_THRESHOLD_SECONDS = 24 * 60 * 60;
|
|
11
|
+
|
|
12
|
+
type KillCandidate = {
|
|
13
|
+
sessionName: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export class StaleTmuxSessionKillUseCase {
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly issueRepository: Pick<IssueRepository, 'getAllOpened'>,
|
|
20
|
+
private readonly tmuxSessionRepository: Pick<
|
|
21
|
+
TmuxSessionRepository,
|
|
22
|
+
'listLiveSessionsWithActivity' | 'killSession'
|
|
23
|
+
>,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
run = async (params: {
|
|
27
|
+
project: Project;
|
|
28
|
+
allowCacheMinutes: number;
|
|
29
|
+
excludedStatus: string;
|
|
30
|
+
idleThresholdSeconds: number;
|
|
31
|
+
now: Date;
|
|
32
|
+
}): Promise<void> => {
|
|
33
|
+
const liveSessions =
|
|
34
|
+
await this.tmuxSessionRepository.listLiveSessionsWithActivity();
|
|
35
|
+
const openIssues = await this.issueRepository.getAllOpened(
|
|
36
|
+
params.project,
|
|
37
|
+
params.allowCacheMinutes,
|
|
38
|
+
);
|
|
39
|
+
const issueBySessionName = new Map<string, Issue>();
|
|
40
|
+
for (const issue of openIssues) {
|
|
41
|
+
issueBySessionName.set(toTmuxSessionName(issue.url), issue);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const nowEpochSeconds = Math.floor(params.now.getTime() / 1000);
|
|
45
|
+
const killCandidates: KillCandidate[] = [];
|
|
46
|
+
for (const session of liveSessions) {
|
|
47
|
+
const reason = this.evaluateKillReason(
|
|
48
|
+
session,
|
|
49
|
+
issueBySessionName.get(session.sessionName) ?? null,
|
|
50
|
+
nowEpochSeconds,
|
|
51
|
+
params.excludedStatus,
|
|
52
|
+
params.idleThresholdSeconds,
|
|
53
|
+
);
|
|
54
|
+
if (reason !== null) {
|
|
55
|
+
killCandidates.push({ sessionName: session.sessionName, reason });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log(
|
|
60
|
+
`Stale tmux session cleanup: ${killCandidates.length} kill candidate(s) of ${liveSessions.length} live session(s).`,
|
|
61
|
+
);
|
|
62
|
+
for (const candidate of killCandidates) {
|
|
63
|
+
console.log(
|
|
64
|
+
`Kill candidate: ${candidate.sessionName} (${candidate.reason})`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const candidate of killCandidates) {
|
|
69
|
+
await this.tmuxSessionRepository.killSession(candidate.sessionName);
|
|
70
|
+
console.log(
|
|
71
|
+
`Killed tmux session: ${candidate.sessionName} (${candidate.reason})`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
private evaluateKillReason = (
|
|
77
|
+
session: LiveTmuxSession,
|
|
78
|
+
issue: Issue | null,
|
|
79
|
+
nowEpochSeconds: number,
|
|
80
|
+
excludedStatus: string,
|
|
81
|
+
idleThresholdSeconds: number,
|
|
82
|
+
): string | null => {
|
|
83
|
+
if (issue !== null) {
|
|
84
|
+
if (issue.status !== excludedStatus) {
|
|
85
|
+
return `mapped to open issue ${issue.url} with status "${issue.status ?? 'null'}" which is not the excluded status "${excludedStatus}"`;
|
|
86
|
+
}
|
|
87
|
+
if (issue.nextActionDate !== null) {
|
|
88
|
+
return `mapped to open issue ${issue.url} which has a next action date set`;
|
|
89
|
+
}
|
|
90
|
+
if (issue.nextActionHour !== null) {
|
|
91
|
+
return `mapped to open issue ${issue.url} which has a next action hour set`;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const idleSeconds = nowEpochSeconds - session.activityEpochSeconds;
|
|
97
|
+
if (idleSeconds >= idleThresholdSeconds) {
|
|
98
|
+
return `maps to no open issue and has been idle for ${idleSeconds} seconds (threshold ${idleThresholdSeconds} seconds)`;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { LiveTmuxSession } from '../../entities/LiveTmuxSession';
|
|
2
|
+
|
|
1
3
|
export interface TmuxSessionRepository {
|
|
2
4
|
listLiveSessionNames: () => Promise<string[]>;
|
|
5
|
+
listLiveSessionsWithActivity: () => Promise<LiveTmuxSession[]>;
|
|
3
6
|
listInteractiveProcessCommandLines: () => Promise<string[]>;
|
|
4
7
|
launchDetachedSession: (
|
|
5
8
|
sessionName: string,
|
|
6
9
|
launcherCommand: string,
|
|
7
10
|
issueUrl: string,
|
|
8
11
|
) => Promise<void>;
|
|
12
|
+
killSession: (sessionName: string) => Promise<void>;
|
|
9
13
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Issue } from '../../entities/Issue';
|
|
2
|
+
import { GenerateDashboardRowUseCase } from './GenerateDashboardRowUseCase';
|
|
3
|
+
|
|
4
|
+
const ASSIGNEE = 'HiromiShikata';
|
|
5
|
+
|
|
6
|
+
let issueCounter = 0;
|
|
7
|
+
const makeIssue = (overrides: Partial<Issue>): Issue => {
|
|
8
|
+
issueCounter += 1;
|
|
9
|
+
return {
|
|
10
|
+
nameWithOwner: 'demo/repo',
|
|
11
|
+
number: issueCounter,
|
|
12
|
+
title: `Issue ${issueCounter}`,
|
|
13
|
+
state: 'OPEN',
|
|
14
|
+
status: null,
|
|
15
|
+
story: null,
|
|
16
|
+
nextActionDate: null,
|
|
17
|
+
nextActionHour: null,
|
|
18
|
+
estimationMinutes: null,
|
|
19
|
+
dependedIssueUrls: [],
|
|
20
|
+
completionDate50PercentConfidence: null,
|
|
21
|
+
url: `https://github.com/demo/repo/issues/${issueCounter}`,
|
|
22
|
+
assignees: [ASSIGNEE],
|
|
23
|
+
labels: [],
|
|
24
|
+
org: 'demo',
|
|
25
|
+
repo: 'repo',
|
|
26
|
+
body: '',
|
|
27
|
+
itemId: `item-${issueCounter}`,
|
|
28
|
+
isPr: false,
|
|
29
|
+
isInProgress: false,
|
|
30
|
+
isClosed: false,
|
|
31
|
+
createdAt: new Date('2026-06-13T08:18:45.000Z'),
|
|
32
|
+
author: 'someone',
|
|
33
|
+
closingIssueReferenceUrls: [],
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('GenerateDashboardRowUseCase', () => {
|
|
39
|
+
const usecase = new GenerateDashboardRowUseCase();
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
issueCounter = 0;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns all-zero counts for an empty issue list', () => {
|
|
46
|
+
expect(usecase.run({ issues: [], assigneeLogin: ASSIGNEE })).toEqual({
|
|
47
|
+
unread: 0,
|
|
48
|
+
todo: 0,
|
|
49
|
+
qc: 0,
|
|
50
|
+
fail: 0,
|
|
51
|
+
pr: 0,
|
|
52
|
+
ws: 0,
|
|
53
|
+
dep: 0,
|
|
54
|
+
blocker: 0,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('counts actionable Unread, Todo, Awaiting Quality Check and Awaiting Workspace issues', () => {
|
|
59
|
+
const issues = [
|
|
60
|
+
makeIssue({ status: 'Unread' }),
|
|
61
|
+
makeIssue({ status: 'Todo by human' }),
|
|
62
|
+
makeIssue({ status: 'Awaiting Quality Check' }),
|
|
63
|
+
makeIssue({ status: 'Awaiting Workspace' }),
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
expect(usecase.run({ issues, assigneeLogin: ASSIGNEE })).toEqual({
|
|
67
|
+
unread: 1,
|
|
68
|
+
todo: 1,
|
|
69
|
+
qc: 1,
|
|
70
|
+
fail: 0,
|
|
71
|
+
pr: 0,
|
|
72
|
+
ws: 1,
|
|
73
|
+
dep: 0,
|
|
74
|
+
blocker: 0,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('excludes non-actionable issues from actionable status columns', () => {
|
|
79
|
+
const issues = [
|
|
80
|
+
makeIssue({ status: 'Unread', nextActionDate: new Date() }),
|
|
81
|
+
makeIssue({ status: 'Unread', nextActionHour: 9 }),
|
|
82
|
+
makeIssue({
|
|
83
|
+
status: 'Awaiting Quality Check',
|
|
84
|
+
dependedIssueUrls: ['https://github.com/demo/repo/issues/999'],
|
|
85
|
+
}),
|
|
86
|
+
makeIssue({ status: 'Todo by human', assignees: ['someone-else'] }),
|
|
87
|
+
makeIssue({ status: 'Awaiting Workspace', isClosed: true }),
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
expect(usecase.run({ issues, assigneeLogin: ASSIGNEE })).toEqual({
|
|
91
|
+
unread: 0,
|
|
92
|
+
todo: 0,
|
|
93
|
+
qc: 0,
|
|
94
|
+
fail: 0,
|
|
95
|
+
pr: 0,
|
|
96
|
+
ws: 0,
|
|
97
|
+
dep: 0,
|
|
98
|
+
blocker: 0,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('counts Preparation and Failed Preparation by whole status without the actionable predicate', () => {
|
|
103
|
+
const issues = [
|
|
104
|
+
makeIssue({ status: 'Preparation', nextActionHour: 9 }),
|
|
105
|
+
makeIssue({
|
|
106
|
+
status: 'Failed Preparation',
|
|
107
|
+
dependedIssueUrls: ['https://github.com/demo/repo/issues/999'],
|
|
108
|
+
}),
|
|
109
|
+
makeIssue({ status: 'Preparation', isClosed: true }),
|
|
110
|
+
makeIssue({ status: 'Failed Preparation', assignees: ['someone-else'] }),
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
expect(usecase.run({ issues, assigneeLogin: ASSIGNEE })).toEqual({
|
|
114
|
+
unread: 0,
|
|
115
|
+
todo: 0,
|
|
116
|
+
qc: 0,
|
|
117
|
+
fail: 1,
|
|
118
|
+
pr: 1,
|
|
119
|
+
ws: 0,
|
|
120
|
+
dep: 0,
|
|
121
|
+
blocker: 0,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('counts dep as Awaiting Workspace issues blocked by a dependency, and never as ws', () => {
|
|
126
|
+
const issues = [
|
|
127
|
+
makeIssue({ status: 'Awaiting Workspace' }),
|
|
128
|
+
makeIssue({
|
|
129
|
+
status: 'Awaiting Workspace',
|
|
130
|
+
dependedIssueUrls: ['https://github.com/demo/repo/issues/999'],
|
|
131
|
+
}),
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
expect(usecase.run({ issues, assigneeLogin: ASSIGNEE })).toMatchObject({
|
|
135
|
+
ws: 1,
|
|
136
|
+
dep: 1,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('counts blocker by case-insensitive workflow blocker story membership for non-closed mine issues', () => {
|
|
141
|
+
const issues = [
|
|
142
|
+
makeIssue({ status: 'Unread', story: 'Workflow Blocker / urgent' }),
|
|
143
|
+
makeIssue({ status: 'Awaiting Workspace', story: 'workflow blocker' }),
|
|
144
|
+
makeIssue({ status: 'Unread', story: 'regular / maintenance' }),
|
|
145
|
+
makeIssue({
|
|
146
|
+
status: 'Unread',
|
|
147
|
+
story: 'workflow blocker',
|
|
148
|
+
isClosed: true,
|
|
149
|
+
}),
|
|
150
|
+
makeIssue({
|
|
151
|
+
status: 'Unread',
|
|
152
|
+
story: 'workflow blocker',
|
|
153
|
+
assignees: ['someone-else'],
|
|
154
|
+
}),
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
expect(usecase.run({ issues, assigneeLogin: ASSIGNEE }).blocker).toBe(2);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Issue } from '../../entities/Issue';
|
|
2
|
+
import {
|
|
3
|
+
AWAITING_QUALITY_CHECK_STATUS_NAME,
|
|
4
|
+
AWAITING_WORKSPACE_STATUS_NAME,
|
|
5
|
+
DEFAULT_STATUS_NAME,
|
|
6
|
+
FAILED_PREPARATION_STATUS_NAME,
|
|
7
|
+
PREPARATION_STATUS_NAME,
|
|
8
|
+
TODO_STATUS_NAME,
|
|
9
|
+
} from '../../entities/WorkflowStatus';
|
|
10
|
+
|
|
11
|
+
export type DashboardRow = {
|
|
12
|
+
unread: number;
|
|
13
|
+
todo: number;
|
|
14
|
+
qc: number;
|
|
15
|
+
fail: number;
|
|
16
|
+
pr: number;
|
|
17
|
+
ws: number;
|
|
18
|
+
dep: number;
|
|
19
|
+
blocker: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type GenerateDashboardRowInput = {
|
|
23
|
+
issues: Issue[];
|
|
24
|
+
assigneeLogin: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const WORKFLOW_BLOCKER_STORY_MARKER = 'workflow blocker';
|
|
28
|
+
|
|
29
|
+
export class GenerateDashboardRowUseCase {
|
|
30
|
+
run = (input: GenerateDashboardRowInput): DashboardRow => {
|
|
31
|
+
const { issues, assigneeLogin } = input;
|
|
32
|
+
|
|
33
|
+
const mine = (issue: Issue): boolean =>
|
|
34
|
+
issue.isClosed === false && issue.assignees.includes(assigneeLogin);
|
|
35
|
+
|
|
36
|
+
const actionable = (issue: Issue): boolean =>
|
|
37
|
+
mine(issue) &&
|
|
38
|
+
issue.dependedIssueUrls.length === 0 &&
|
|
39
|
+
issue.nextActionDate === null &&
|
|
40
|
+
issue.nextActionHour === null;
|
|
41
|
+
|
|
42
|
+
const countActionableWithStatus = (statusName: string): number =>
|
|
43
|
+
issues.filter((issue) => issue.status === statusName && actionable(issue))
|
|
44
|
+
.length;
|
|
45
|
+
|
|
46
|
+
const countMineWithStatus = (statusName: string): number =>
|
|
47
|
+
issues.filter((issue) => mine(issue) && issue.status === statusName)
|
|
48
|
+
.length;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
unread: countActionableWithStatus(DEFAULT_STATUS_NAME),
|
|
52
|
+
todo: countActionableWithStatus(TODO_STATUS_NAME),
|
|
53
|
+
qc: countActionableWithStatus(AWAITING_QUALITY_CHECK_STATUS_NAME),
|
|
54
|
+
fail: countMineWithStatus(FAILED_PREPARATION_STATUS_NAME),
|
|
55
|
+
pr: countMineWithStatus(PREPARATION_STATUS_NAME),
|
|
56
|
+
ws: countActionableWithStatus(AWAITING_WORKSPACE_STATUS_NAME),
|
|
57
|
+
dep: issues.filter(
|
|
58
|
+
(issue) =>
|
|
59
|
+
mine(issue) &&
|
|
60
|
+
issue.status === AWAITING_WORKSPACE_STATUS_NAME &&
|
|
61
|
+
issue.dependedIssueUrls.length > 0,
|
|
62
|
+
).length,
|
|
63
|
+
blocker: issues.filter(
|
|
64
|
+
(issue) =>
|
|
65
|
+
mine(issue) &&
|
|
66
|
+
(issue.story ?? '')
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.includes(WORKFLOW_BLOCKER_STORY_MARKER),
|
|
69
|
+
).length,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
}
|