github-issue-tower-defence-management 1.105.0 → 1.107.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -0
  3. package/bin/adapter/entry-points/cli/index.js +18 -1
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/dashboardComposeService.js +178 -0
  6. package/bin/adapter/entry-points/console/dashboardComposeService.js.map +1 -0
  7. package/bin/adapter/entry-points/console/webServer.js +29 -7
  8. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +13 -0
  10. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  11. package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js +18 -0
  12. package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js.map +1 -0
  13. package/bin/adapter/repositories/NodeTmuxSessionRepository.js +22 -0
  14. package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
  15. package/bin/domain/entities/LiveTmuxSession.js +3 -0
  16. package/bin/domain/entities/LiveTmuxSession.js.map +1 -0
  17. package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js +58 -0
  18. package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js.map +1 -0
  19. package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js +161 -0
  20. package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js.map +1 -0
  21. package/package.json +1 -1
  22. package/src/adapter/entry-points/cli/index.ts +36 -2
  23. package/src/adapter/entry-points/console/dashboardComposeService.test.ts +443 -0
  24. package/src/adapter/entry-points/console/dashboardComposeService.ts +200 -0
  25. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -0
  26. package/src/adapter/entry-points/console/webServer.test.ts +255 -54
  27. package/src/adapter/entry-points/console/webServer.ts +43 -10
  28. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +17 -0
  29. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +170 -0
  30. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.ts +40 -0
  31. package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +81 -0
  32. package/src/adapter/repositories/NodeTmuxSessionRepository.ts +35 -0
  33. package/src/domain/entities/LiveTmuxSession.ts +4 -0
  34. package/src/domain/usecases/StaleTmuxSessionKillUseCase.test.ts +286 -0
  35. package/src/domain/usecases/StaleTmuxSessionKillUseCase.ts +102 -0
  36. package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +4 -0
  37. package/src/domain/usecases/dashboard/ComposeDashboardUseCase.test.ts +405 -0
  38. package/src/domain/usecases/dashboard/ComposeDashboardUseCase.ts +220 -0
  39. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +6 -0
  40. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  41. package/types/adapter/entry-points/console/dashboardComposeService.d.ts +9 -0
  42. package/types/adapter/entry-points/console/dashboardComposeService.d.ts.map +1 -0
  43. package/types/adapter/entry-points/console/webServer.d.ts +4 -0
  44. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  45. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  46. package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts +12 -0
  47. package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts.map +1 -0
  48. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +3 -0
  49. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
  50. package/types/domain/entities/LiveTmuxSession.d.ts +5 -0
  51. package/types/domain/entities/LiveTmuxSession.d.ts.map +1 -0
  52. package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts +19 -0
  53. package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts.map +1 -0
  54. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +3 -0
  55. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
  56. package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts +28 -0
  57. package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts.map +1 -0
@@ -0,0 +1,170 @@
1
+ import { Issue } from '../../../domain/entities/Issue';
2
+ import { Project } from '../../../domain/entities/Project';
3
+ import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
4
+ import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
5
+ import { IN_TMUX_STATUS_NAME } from '../../../domain/entities/WorkflowStatus';
6
+ import { cleanStaleTmuxSessions } from './staleTmuxSessionCleaner';
7
+
8
+ const NOW = new Date('2026-06-26T00:00:00.000Z');
9
+ const NOW_EPOCH_SECONDS = Math.floor(NOW.getTime() / 1000);
10
+ const ALLOW_CACHE_MINUTES = 10;
11
+
12
+ const makeProject = (): Project => ({
13
+ id: 'project-1',
14
+ url: 'https://github.com/users/user/projects/1',
15
+ databaseId: 1,
16
+ name: 'Test Project',
17
+ status: {
18
+ name: 'Status',
19
+ fieldId: 'field-1',
20
+ statuses: [],
21
+ },
22
+ nextActionDate: null,
23
+ nextActionHour: null,
24
+ story: null,
25
+ remainingEstimationMinutes: null,
26
+ dependedIssueUrlSeparatedByComma: null,
27
+ completionDate50PercentConfidence: null,
28
+ });
29
+
30
+ const makeIssue = (overrides: Partial<Issue> = {}): Issue => ({
31
+ nameWithOwner: 'demo/repo',
32
+ number: 1,
33
+ title: 'Issue 1',
34
+ state: 'OPEN',
35
+ status: IN_TMUX_STATUS_NAME,
36
+ story: null,
37
+ nextActionDate: null,
38
+ nextActionHour: null,
39
+ estimationMinutes: null,
40
+ dependedIssueUrls: [],
41
+ completionDate50PercentConfidence: null,
42
+ url: 'https://github.com/demo/repo/issues/1',
43
+ assignees: [],
44
+ labels: [],
45
+ org: 'demo',
46
+ repo: 'repo',
47
+ body: '',
48
+ itemId: 'item-1',
49
+ isPr: false,
50
+ isInProgress: false,
51
+ isClosed: false,
52
+ createdAt: new Date(),
53
+ author: '',
54
+ closingIssueReferenceUrls: [],
55
+ ...overrides,
56
+ });
57
+
58
+ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
59
+
60
+ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
61
+ runCommand: jest.fn(),
62
+ });
63
+
64
+ const createMockIssueRepository = (
65
+ issues: Issue[],
66
+ ): Pick<IssueRepository, 'getAllOpened'> => ({
67
+ getAllOpened: jest.fn().mockResolvedValue(issues),
68
+ });
69
+
70
+ describe('cleanStaleTmuxSessions', () => {
71
+ beforeEach(() => {
72
+ jest.spyOn(console, 'log').mockImplementation(() => undefined);
73
+ });
74
+
75
+ afterEach(() => {
76
+ jest.restoreAllMocks();
77
+ });
78
+
79
+ it('kills a session mapping to an open issue whose status is not the excluded status', async () => {
80
+ const runner = createMockRunner();
81
+ runner.runCommand.mockImplementation(async (program, args) => {
82
+ if (program === 'tmux' && args[0] === 'list-sessions') {
83
+ return {
84
+ stdout: `https_//github_com/demo/repo/issues/1 ${NOW_EPOCH_SECONDS}\n`,
85
+ stderr: '',
86
+ exitCode: 0,
87
+ };
88
+ }
89
+ return { stdout: '', stderr: '', exitCode: 0 };
90
+ });
91
+
92
+ await cleanStaleTmuxSessions({
93
+ project: makeProject(),
94
+ allowCacheMinutes: ALLOW_CACHE_MINUTES,
95
+ issueRepository: createMockIssueRepository([
96
+ makeIssue({ status: 'In Progress' }),
97
+ ]),
98
+ localCommandRunner: runner,
99
+ now: NOW,
100
+ });
101
+
102
+ const killCall = runner.runCommand.mock.calls.find(
103
+ (call) => call[0] === 'tmux' && call[1][0] === 'kill-session',
104
+ );
105
+ expect(killCall?.[1]).toEqual([
106
+ 'kill-session',
107
+ '-t',
108
+ 'https_//github_com/demo/repo/issues/1',
109
+ ]);
110
+ });
111
+
112
+ it('does not kill an excluded-status session that has no reactivation trigger', async () => {
113
+ const runner = createMockRunner();
114
+ runner.runCommand.mockImplementation(async (program, args) => {
115
+ if (program === 'tmux' && args[0] === 'list-sessions') {
116
+ return {
117
+ stdout: `https_//github_com/demo/repo/issues/1 ${NOW_EPOCH_SECONDS}\n`,
118
+ stderr: '',
119
+ exitCode: 0,
120
+ };
121
+ }
122
+ return { stdout: '', stderr: '', exitCode: 0 };
123
+ });
124
+
125
+ await cleanStaleTmuxSessions({
126
+ project: makeProject(),
127
+ allowCacheMinutes: ALLOW_CACHE_MINUTES,
128
+ issueRepository: createMockIssueRepository([
129
+ makeIssue({ status: IN_TMUX_STATUS_NAME }),
130
+ ]),
131
+ localCommandRunner: runner,
132
+ now: NOW,
133
+ });
134
+
135
+ const killCalls = runner.runCommand.mock.calls.filter(
136
+ (call) => call[0] === 'tmux' && call[1][0] === 'kill-session',
137
+ );
138
+ expect(killCalls).toHaveLength(0);
139
+ });
140
+
141
+ it('kills a no-task session idle at least 24 hours and spares a recently active one', async () => {
142
+ const runner = createMockRunner();
143
+ const idleActivity = NOW_EPOCH_SECONDS - 24 * 60 * 60;
144
+ const recentActivity = NOW_EPOCH_SECONDS - 24 * 60 * 60 + 1;
145
+ runner.runCommand.mockImplementation(async (program, args) => {
146
+ if (program === 'tmux' && args[0] === 'list-sessions') {
147
+ return {
148
+ stdout: `idle_no_task ${idleActivity}\nrecent_no_task ${recentActivity}\n`,
149
+ stderr: '',
150
+ exitCode: 0,
151
+ };
152
+ }
153
+ return { stdout: '', stderr: '', exitCode: 0 };
154
+ });
155
+
156
+ await cleanStaleTmuxSessions({
157
+ project: makeProject(),
158
+ allowCacheMinutes: ALLOW_CACHE_MINUTES,
159
+ issueRepository: createMockIssueRepository([]),
160
+ localCommandRunner: runner,
161
+ now: NOW,
162
+ });
163
+
164
+ const killCalls = runner.runCommand.mock.calls.filter(
165
+ (call) => call[0] === 'tmux' && call[1][0] === 'kill-session',
166
+ );
167
+ expect(killCalls).toHaveLength(1);
168
+ expect(killCalls[0][1]).toEqual(['kill-session', '-t', 'idle_no_task']);
169
+ });
170
+ });
@@ -0,0 +1,40 @@
1
+ import { Project } from '../../../domain/entities/Project';
2
+ import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
3
+ import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
4
+ import {
5
+ StaleTmuxSessionKillUseCase,
6
+ DEFAULT_EXCLUDED_STATUS,
7
+ DEFAULT_IDLE_THRESHOLD_SECONDS,
8
+ } from '../../../domain/usecases/StaleTmuxSessionKillUseCase';
9
+ import { NodeTmuxSessionRepository } from '../../repositories/NodeTmuxSessionRepository';
10
+
11
+ export type CleanStaleTmuxSessionsParams = {
12
+ project: Project;
13
+ allowCacheMinutes: number;
14
+ issueRepository: Pick<IssueRepository, 'getAllOpened'>;
15
+ localCommandRunner: LocalCommandRunner;
16
+ now: Date;
17
+ };
18
+
19
+ export const cleanStaleTmuxSessions = async (
20
+ params: CleanStaleTmuxSessionsParams,
21
+ ): Promise<void> => {
22
+ const {
23
+ project,
24
+ allowCacheMinutes,
25
+ issueRepository,
26
+ localCommandRunner,
27
+ now,
28
+ } = params;
29
+ const useCase = new StaleTmuxSessionKillUseCase(
30
+ issueRepository,
31
+ new NodeTmuxSessionRepository(localCommandRunner),
32
+ );
33
+ await useCase.run({
34
+ project,
35
+ allowCacheMinutes,
36
+ excludedStatus: DEFAULT_EXCLUDED_STATUS,
37
+ idleThresholdSeconds: DEFAULT_IDLE_THRESHOLD_SECONDS,
38
+ now,
39
+ });
40
+ };
@@ -44,6 +44,87 @@ describe('NodeTmuxSessionRepository', () => {
44
44
  });
45
45
  });
46
46
 
47
+ describe('listLiveSessionsWithActivity', () => {
48
+ it('parses session names and activity epoch seconds and drops blank lines', async () => {
49
+ const runner = createMockRunner();
50
+ runner.runCommand.mockResolvedValue({
51
+ stdout:
52
+ 'https_//github_com/owner/repo/issues/9 1700000000\nno_task_session 1699000000\n\n',
53
+ stderr: '',
54
+ exitCode: 0,
55
+ });
56
+ const repository = new NodeTmuxSessionRepository(runner);
57
+
58
+ const result = await repository.listLiveSessionsWithActivity();
59
+
60
+ expect(result).toEqual([
61
+ {
62
+ sessionName: 'https_//github_com/owner/repo/issues/9',
63
+ activityEpochSeconds: 1700000000,
64
+ },
65
+ {
66
+ sessionName: 'no_task_session',
67
+ activityEpochSeconds: 1699000000,
68
+ },
69
+ ]);
70
+ expect(runner.runCommand.mock.calls[0][0]).toBe('tmux');
71
+ expect(runner.runCommand.mock.calls[0][1]).toEqual([
72
+ 'list-sessions',
73
+ '-F',
74
+ '#{session_name} #{session_activity}',
75
+ ]);
76
+ });
77
+
78
+ it('returns an empty list when tmux exits non-zero', async () => {
79
+ const runner = createMockRunner();
80
+ runner.runCommand.mockResolvedValue({
81
+ stdout: '',
82
+ stderr: 'no server running',
83
+ exitCode: 1,
84
+ });
85
+ const repository = new NodeTmuxSessionRepository(runner);
86
+
87
+ const result = await repository.listLiveSessionsWithActivity();
88
+
89
+ expect(result).toEqual([]);
90
+ });
91
+ });
92
+
93
+ describe('killSession', () => {
94
+ it('kills the tmux session by name', async () => {
95
+ const runner = createMockRunner();
96
+ runner.runCommand.mockResolvedValue({
97
+ stdout: '',
98
+ stderr: '',
99
+ exitCode: 0,
100
+ });
101
+ const repository = new NodeTmuxSessionRepository(runner);
102
+
103
+ await repository.killSession('no_task_session');
104
+
105
+ expect(runner.runCommand.mock.calls[0][0]).toBe('tmux');
106
+ expect(runner.runCommand.mock.calls[0][1]).toEqual([
107
+ 'kill-session',
108
+ '-t',
109
+ 'no_task_session',
110
+ ]);
111
+ });
112
+
113
+ it('throws when tmux exits non-zero', async () => {
114
+ const runner = createMockRunner();
115
+ runner.runCommand.mockResolvedValue({
116
+ stdout: '',
117
+ stderr: "can't find session",
118
+ exitCode: 1,
119
+ });
120
+ const repository = new NodeTmuxSessionRepository(runner);
121
+
122
+ await expect(repository.killSession('missing_session')).rejects.toThrow(
123
+ 'Failed to kill tmux session "missing_session": exit code 1',
124
+ );
125
+ });
126
+ });
127
+
47
128
  describe('listInteractiveProcessCommandLines', () => {
48
129
  it('parses process command lines from ps output', async () => {
49
130
  const runner = createMockRunner();
@@ -1,5 +1,6 @@
1
1
  import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
2
2
  import { TmuxSessionRepository } from '../../domain/usecases/adapter-interfaces/TmuxSessionRepository';
3
+ import { LiveTmuxSession } from '../../domain/entities/LiveTmuxSession';
3
4
 
4
5
  export class NodeTmuxSessionRepository implements TmuxSessionRepository {
5
6
  constructor(private readonly localCommandRunner: LocalCommandRunner) {}
@@ -18,6 +19,26 @@ export class NodeTmuxSessionRepository implements TmuxSessionRepository {
18
19
  .filter((line) => line.length > 0);
19
20
  };
20
21
 
22
+ listLiveSessionsWithActivity = async (): Promise<LiveTmuxSession[]> => {
23
+ const { stdout, exitCode } = await this.localCommandRunner.runCommand(
24
+ 'tmux',
25
+ ['list-sessions', '-F', '#{session_name} #{session_activity}'],
26
+ );
27
+ if (exitCode !== 0) {
28
+ return [];
29
+ }
30
+ return stdout
31
+ .split('\n')
32
+ .map((line) => line.trim())
33
+ .filter((line) => line.length > 0)
34
+ .map((line) => {
35
+ const separatorIndex = line.lastIndexOf(' ');
36
+ const sessionName = line.slice(0, separatorIndex);
37
+ const activityEpochSeconds = Number(line.slice(separatorIndex + 1));
38
+ return { sessionName, activityEpochSeconds };
39
+ });
40
+ };
41
+
21
42
  listInteractiveProcessCommandLines = async (): Promise<string[]> => {
22
43
  const { stdout, exitCode } = await this.localCommandRunner.runCommand(
23
44
  'ps',
@@ -51,4 +72,18 @@ export class NodeTmuxSessionRepository implements TmuxSessionRepository {
51
72
  issueUrl,
52
73
  ]);
53
74
  };
75
+
76
+ killSession = async (sessionName: string): Promise<void> => {
77
+ const { stderr, exitCode } = await this.localCommandRunner.runCommand(
78
+ 'tmux',
79
+ ['kill-session', '-t', sessionName],
80
+ );
81
+ if (exitCode !== 0) {
82
+ throw new Error(
83
+ `Failed to kill tmux session "${sessionName}": exit code ${exitCode}${
84
+ stderr ? `: ${stderr}` : ''
85
+ }`,
86
+ );
87
+ }
88
+ };
54
89
  }
@@ -0,0 +1,4 @@
1
+ export type LiveTmuxSession = {
2
+ sessionName: string;
3
+ activityEpochSeconds: number;
4
+ };
@@ -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
+ });