github-issue-tower-defence-management 1.146.4 → 1.146.6
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/.github/dependabot.yml +2 -0
- package/.github/workflows/codeql.yml +2 -2
- package/.github/workflows/commit-lint.yml +20 -0
- package/.github/workflows/umino-project.yml +0 -1
- package/CHANGELOG.md +14 -0
- package/bin/adapter/entry-points/cli/index.js +2 -2
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-CPY6wV8C.js +82 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js +26 -100
- package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +49 -576
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +4 -3
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +4 -3
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +3 -2
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
- package/bin/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.js +1 -1
- package/bin/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.js.map +1 -1
- package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js +73 -2
- package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +31 -7
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +3 -1
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -1
- package/bin/domain/usecases/OauthTokenSelectUseCase.js +13 -4
- package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
- package/package.json +5 -7
- package/renovate.json +5 -0
- package/src/adapter/entry-points/cli/index.ts +2 -2
- package/src/adapter/entry-points/console/consoleGithubTokenResolver.test.ts +22 -20
- package/src/adapter/entry-points/console/consoleOperationApi.test.ts +2 -2
- package/src/adapter/entry-points/console/ui-dist/assets/index-CPY6wV8C.js +82 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/entry-points/console/webServer.test.ts +2 -2
- package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.ts +31 -3
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +68 -3
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +7 -4
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +4 -1
- package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +6 -2
- package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +8 -2
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +1 -0
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +14 -2
- package/src/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.ts +4 -1
- package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +157 -15
- package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.ts +115 -1
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +35 -4
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +77 -5
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +10 -1
- package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +39 -8
- package/src/domain/usecases/OauthTokenSelectUseCase.ts +25 -5
- package/src/domain/usecases/ProjectRequiredFieldCreateUseCase.test.ts +3 -3
- package/tsconfig.json +0 -5
- package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts +3 -1
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts +3 -2
- package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +1 -0
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.d.ts.map +1 -1
- package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts +14 -1
- package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -1
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +4 -1
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-Bmc_nF1d.js +0 -82
- package/src/adapter/entry-points/console/ui-dist/assets/index-Bmc_nF1d.js +0 -82
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>TDPM Console</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-CPY6wV8C.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-CKX2ny-z.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
@@ -651,11 +651,11 @@ describe('webServer new routes integration', () => {
|
|
|
651
651
|
const response = await request(
|
|
652
652
|
server,
|
|
653
653
|
'GET',
|
|
654
|
-
`/api/itembody?k=${testToken}&url=https://github.com/
|
|
654
|
+
`/api/itembody?k=${testToken}&url=https://github.com/acme-labs/acme-portal-mock/issues/178`,
|
|
655
655
|
);
|
|
656
656
|
expect(JSON.parse(response.body)).toEqual({ body: 'owner token body' });
|
|
657
657
|
expect(resolvedUrls).toEqual([
|
|
658
|
-
'https://github.com/
|
|
658
|
+
'https://github.com/acme-labs/acme-portal-mock/issues/178',
|
|
659
659
|
]);
|
|
660
660
|
expect(
|
|
661
661
|
defaultIssueRepository.getIssueOrPullRequestBody,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import YAML from 'yaml';
|
|
2
|
-
import TYPIA from 'typia';
|
|
3
2
|
import fs from 'fs';
|
|
4
3
|
import { LocalStorageRepository } from '../../repositories/LocalStorageRepository';
|
|
5
4
|
import { GraphqlProjectRepository } from '../../repositories/GraphqlProjectRepository';
|
|
@@ -38,9 +37,38 @@ export class GetStoryObjectMapUseCaseHandler {
|
|
|
38
37
|
};
|
|
39
38
|
};
|
|
40
39
|
|
|
41
|
-
|
|
40
|
+
const isInputType = (v: unknown): v is inputType => {
|
|
41
|
+
if (typeof v !== 'object' || v === null) return false;
|
|
42
|
+
if (!('projectName' in v) || typeof v.projectName !== 'string')
|
|
43
|
+
return false;
|
|
44
|
+
if (
|
|
45
|
+
!('credentials' in v) ||
|
|
46
|
+
typeof v.credentials !== 'object' ||
|
|
47
|
+
v.credentials === null
|
|
48
|
+
)
|
|
49
|
+
return false;
|
|
50
|
+
const credentials = v.credentials;
|
|
51
|
+
if (
|
|
52
|
+
!('bot' in credentials) ||
|
|
53
|
+
typeof credentials.bot !== 'object' ||
|
|
54
|
+
credentials.bot === null
|
|
55
|
+
)
|
|
56
|
+
return false;
|
|
57
|
+
const bot = credentials.bot;
|
|
58
|
+
if (
|
|
59
|
+
!('github' in bot) ||
|
|
60
|
+
typeof bot.github !== 'object' ||
|
|
61
|
+
bot.github === null
|
|
62
|
+
)
|
|
63
|
+
return false;
|
|
64
|
+
const github = bot.github;
|
|
65
|
+
if (!('token' in github) || typeof github.token !== 'string')
|
|
66
|
+
return false;
|
|
67
|
+
return true;
|
|
68
|
+
};
|
|
69
|
+
if (!isInputType(input)) {
|
|
42
70
|
throw new Error(
|
|
43
|
-
`Invalid input:
|
|
71
|
+
`Invalid input: required fields projectName and credentials.bot.github.token must be strings. Got: ${JSON.stringify(input)}`,
|
|
44
72
|
);
|
|
45
73
|
}
|
|
46
74
|
const localStorageRepository = new LocalStorageRepository();
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import YAML from 'yaml';
|
|
2
|
-
import TYPIA from 'typia';
|
|
3
2
|
import fs from 'fs';
|
|
4
3
|
import os from 'os';
|
|
5
4
|
import path from 'path';
|
|
@@ -175,9 +174,75 @@ export class HandleScheduledEventUseCaseHandler {
|
|
|
175
174
|
};
|
|
176
175
|
};
|
|
177
176
|
|
|
178
|
-
|
|
177
|
+
const isInputType = (v: unknown): v is inputType => {
|
|
178
|
+
if (typeof v !== 'object' || v === null) return false;
|
|
179
|
+
if (
|
|
180
|
+
!('credentials' in v) ||
|
|
181
|
+
typeof v.credentials !== 'object' ||
|
|
182
|
+
v.credentials === null
|
|
183
|
+
)
|
|
184
|
+
return false;
|
|
185
|
+
const credentials = v.credentials;
|
|
186
|
+
if (
|
|
187
|
+
!('manager' in credentials) ||
|
|
188
|
+
typeof credentials.manager !== 'object' ||
|
|
189
|
+
credentials.manager === null
|
|
190
|
+
)
|
|
191
|
+
return false;
|
|
192
|
+
const manager = credentials.manager;
|
|
193
|
+
if (
|
|
194
|
+
!('github' in manager) ||
|
|
195
|
+
typeof manager.github !== 'object' ||
|
|
196
|
+
manager.github === null
|
|
197
|
+
)
|
|
198
|
+
return false;
|
|
199
|
+
if (
|
|
200
|
+
!('token' in manager.github) ||
|
|
201
|
+
typeof manager.github.token !== 'string'
|
|
202
|
+
)
|
|
203
|
+
return false;
|
|
204
|
+
if (
|
|
205
|
+
!('slack' in manager) ||
|
|
206
|
+
typeof manager.slack !== 'object' ||
|
|
207
|
+
manager.slack === null
|
|
208
|
+
)
|
|
209
|
+
return false;
|
|
210
|
+
if (
|
|
211
|
+
!('userToken' in manager.slack) ||
|
|
212
|
+
typeof manager.slack.userToken !== 'string'
|
|
213
|
+
)
|
|
214
|
+
return false;
|
|
215
|
+
if (
|
|
216
|
+
!('googleServiceAccount' in manager) ||
|
|
217
|
+
typeof manager.googleServiceAccount !== 'object' ||
|
|
218
|
+
manager.googleServiceAccount === null
|
|
219
|
+
)
|
|
220
|
+
return false;
|
|
221
|
+
if (
|
|
222
|
+
!('serviceAccountKey' in manager.googleServiceAccount) ||
|
|
223
|
+
typeof manager.googleServiceAccount.serviceAccountKey !== 'string'
|
|
224
|
+
)
|
|
225
|
+
return false;
|
|
226
|
+
if (
|
|
227
|
+
!('bot' in credentials) ||
|
|
228
|
+
typeof credentials.bot !== 'object' ||
|
|
229
|
+
credentials.bot === null
|
|
230
|
+
)
|
|
231
|
+
return false;
|
|
232
|
+
const bot = credentials.bot;
|
|
233
|
+
if (
|
|
234
|
+
!('github' in bot) ||
|
|
235
|
+
typeof bot.github !== 'object' ||
|
|
236
|
+
bot.github === null
|
|
237
|
+
)
|
|
238
|
+
return false;
|
|
239
|
+
if (!('token' in bot.github) || typeof bot.github.token !== 'string')
|
|
240
|
+
return false;
|
|
241
|
+
return true;
|
|
242
|
+
};
|
|
243
|
+
if (!isInputType(input)) {
|
|
179
244
|
throw new Error(
|
|
180
|
-
`Invalid input:
|
|
245
|
+
`Invalid input: required credential fields are missing. Got: ${JSON.stringify(input)}`,
|
|
181
246
|
);
|
|
182
247
|
}
|
|
183
248
|
if (input.disabled) {
|
|
@@ -109,10 +109,12 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
109
109
|
|
|
110
110
|
const buildHandler = (
|
|
111
111
|
sessions: ClaudeLiveSession[],
|
|
112
|
+
random: () => number = () => 0.5,
|
|
112
113
|
): LiveSessionOauthTokenSelectHandler =>
|
|
113
114
|
new LiveSessionOauthTokenSelectHandler(
|
|
114
115
|
new LiveSessionOauthTokenSelectUseCase(),
|
|
115
116
|
new FakeClaudeLiveSessionRepository(sessions),
|
|
117
|
+
random,
|
|
116
118
|
);
|
|
117
119
|
|
|
118
120
|
const writeFableRejectionCache = (token: string, resetsAt: number): void => {
|
|
@@ -163,7 +165,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
163
165
|
);
|
|
164
166
|
});
|
|
165
167
|
|
|
166
|
-
it('selects the eligible token with
|
|
168
|
+
it('selects the eligible token with fewer live sessions for a draw inside its weight slice', () => {
|
|
167
169
|
writeTokenList([
|
|
168
170
|
{ name: 'busy', token: 'fake-busy' },
|
|
169
171
|
{ name: 'idle', token: 'fake-idle' },
|
|
@@ -181,9 +183,10 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
181
183
|
sevenDayReset: NOW + 6 * DAY,
|
|
182
184
|
});
|
|
183
185
|
|
|
184
|
-
const handler = buildHandler(
|
|
185
|
-
{ token: 'fake-busy', sessionKey: 'session-a' },
|
|
186
|
-
|
|
186
|
+
const handler = buildHandler(
|
|
187
|
+
[{ token: 'fake-busy', sessionKey: 'session-a' }],
|
|
188
|
+
() => 0.9,
|
|
189
|
+
);
|
|
187
190
|
const output = handler.handle({
|
|
188
191
|
tokenListJsonPath: tokenListPath,
|
|
189
192
|
cacheDirectory,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
FIVE_HOUR_MIN_FREE_RATIO,
|
|
9
9
|
OauthTokenCandidate,
|
|
10
10
|
SEVEN_DAY_MIN_FREE_RATIO,
|
|
11
|
+
SelectionRandom,
|
|
11
12
|
} from '../../../domain/usecases/OauthTokenSelectUseCase';
|
|
12
13
|
import { ProcClaudeLiveSessionRepository } from '../../repositories/ProcClaudeLiveSessionRepository';
|
|
13
14
|
import { FABLE_LIMIT_TYPE, readRateLimit } from '../../proxy/RateLimitCache';
|
|
@@ -33,6 +34,7 @@ export class LiveSessionOauthTokenSelectHandler {
|
|
|
33
34
|
constructor(
|
|
34
35
|
private readonly useCase: LiveSessionOauthTokenSelectUseCase = new LiveSessionOauthTokenSelectUseCase(),
|
|
35
36
|
private readonly liveSessionRepository: ClaudeLiveSessionRepository = new ProcClaudeLiveSessionRepository(),
|
|
37
|
+
private readonly random: SelectionRandom = Math.random,
|
|
36
38
|
) {}
|
|
37
39
|
|
|
38
40
|
handle = (
|
|
@@ -96,6 +98,7 @@ export class LiveSessionOauthTokenSelectHandler {
|
|
|
96
98
|
candidates,
|
|
97
99
|
liveSessions,
|
|
98
100
|
input.nowEpochSeconds,
|
|
101
|
+
this.random,
|
|
99
102
|
);
|
|
100
103
|
|
|
101
104
|
return {
|
|
@@ -125,7 +128,7 @@ export class LiveSessionOauthTokenSelectHandler {
|
|
|
125
128
|
);
|
|
126
129
|
} else {
|
|
127
130
|
lines.push(
|
|
128
|
-
`Selected ${result.selected.name} (
|
|
131
|
+
`Selected ${result.selected.name} (weighted by how soon the 7d window resets, how much of it is free, and how few live sessions the token carries).`,
|
|
129
132
|
);
|
|
130
133
|
}
|
|
131
134
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as os from 'os';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
+
import { OauthTokenSelectUseCase } from '../../../domain/usecases/OauthTokenSelectUseCase';
|
|
4
5
|
import { FABLE_LIMIT_TYPE, hashToken } from '../../proxy/RateLimitCache';
|
|
5
6
|
import {
|
|
6
7
|
OauthTokenSelectHandler,
|
|
@@ -122,7 +123,7 @@ describe('OauthTokenSelectHandler', () => {
|
|
|
122
123
|
);
|
|
123
124
|
};
|
|
124
125
|
|
|
125
|
-
it('selects the
|
|
126
|
+
it('selects the token whose 7d window resets soonest for a draw inside its weight slice', () => {
|
|
126
127
|
writeTokenList([
|
|
127
128
|
{ name: 'far', token: 'fake-far' },
|
|
128
129
|
{ name: 'soon', token: 'fake-soon' },
|
|
@@ -140,7 +141,10 @@ describe('OauthTokenSelectHandler', () => {
|
|
|
140
141
|
sevenDayReset: NOW + 2 * DAY,
|
|
141
142
|
});
|
|
142
143
|
|
|
143
|
-
const handler = new OauthTokenSelectHandler(
|
|
144
|
+
const handler = new OauthTokenSelectHandler(
|
|
145
|
+
new OauthTokenSelectUseCase(),
|
|
146
|
+
() => 0.5,
|
|
147
|
+
);
|
|
144
148
|
const output = handler.handle({
|
|
145
149
|
tokenListJsonPath: tokenListPath,
|
|
146
150
|
cacheDirectory,
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
OauthTokenCandidate,
|
|
4
4
|
OauthTokenSelectResult,
|
|
5
5
|
OauthTokenSelectUseCase,
|
|
6
|
+
SelectionRandom,
|
|
6
7
|
} from '../../../domain/usecases/OauthTokenSelectUseCase';
|
|
7
8
|
import {
|
|
8
9
|
FABLE_LIMIT_TYPE,
|
|
@@ -55,6 +56,7 @@ export const resolveCacheDirectory = (
|
|
|
55
56
|
export class OauthTokenSelectHandler {
|
|
56
57
|
constructor(
|
|
57
58
|
private readonly useCase: OauthTokenSelectUseCase = new OauthTokenSelectUseCase(),
|
|
59
|
+
private readonly random: SelectionRandom = Math.random,
|
|
58
60
|
) {}
|
|
59
61
|
|
|
60
62
|
handle = (
|
|
@@ -112,7 +114,11 @@ export class OauthTokenSelectHandler {
|
|
|
112
114
|
},
|
|
113
115
|
);
|
|
114
116
|
|
|
115
|
-
const result = this.useCase.run(
|
|
117
|
+
const result = this.useCase.run(
|
|
118
|
+
candidates,
|
|
119
|
+
input.nowEpochSeconds,
|
|
120
|
+
this.random,
|
|
121
|
+
);
|
|
116
122
|
|
|
117
123
|
return {
|
|
118
124
|
selectedToken: result.selected?.token ?? null,
|
|
@@ -139,7 +145,7 @@ export class OauthTokenSelectHandler {
|
|
|
139
145
|
lines.push('No eligible token passed the rate-limit filter.');
|
|
140
146
|
} else {
|
|
141
147
|
lines.push(
|
|
142
|
-
`Selected ${result.selected.name} (
|
|
148
|
+
`Selected ${result.selected.name} (weighted by how soon the 7d window resets and how much of it is free among eligible tokens).`,
|
|
143
149
|
);
|
|
144
150
|
}
|
|
145
151
|
|
|
@@ -147,6 +147,7 @@ describe('notifySilentTmuxSessions', () => {
|
|
|
147
147
|
messageTemplates: EMPTY_TEMPLATES,
|
|
148
148
|
now: NOW,
|
|
149
149
|
...DEFAULT_NOTIFY_SILENT_TMUX_SESSIONS_PARAMS,
|
|
150
|
+
submitPushOutWaitMilliseconds: 0,
|
|
150
151
|
});
|
|
151
152
|
|
|
152
153
|
it('sends a main stalled notification to a silent github-named live session that was already a candidate in the previous cycle', async () => {
|
|
@@ -18,7 +18,11 @@ import { LocalProcessLiveSessionProcessSnapshotProvider } from '../../repositori
|
|
|
18
18
|
import { ProcFsProcessEnvironReader } from '../../repositories/ProcFsProcessEnvironReader';
|
|
19
19
|
import { FileSystemInteractiveLiveSessionTranscriptResolver } from '../../repositories/FileSystemInteractiveLiveSessionTranscriptResolver';
|
|
20
20
|
import { FileSystemSessionOutputActivityRepository } from '../../repositories/FileSystemSessionOutputActivityRepository';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_SUBMIT_PUSH_OUT_ATTEMPT_LIMIT,
|
|
23
|
+
DEFAULT_SUBMIT_PUSH_OUT_WAIT_MILLISECONDS,
|
|
24
|
+
TmuxSilentSessionNotificationRepository,
|
|
25
|
+
} from '../../repositories/TmuxSilentSessionNotificationRepository';
|
|
22
26
|
import { NoUnansweredOwnerCallStatusProvider } from '../../repositories/NoUnansweredOwnerCallStatusProvider';
|
|
23
27
|
import { TranscriptOwnerCallStatusProvider } from '../../repositories/TranscriptOwnerCallStatusProvider';
|
|
24
28
|
import { TranscriptRefusalTailStatusProvider } from '../../repositories/TranscriptRefusalTailStatusProvider';
|
|
@@ -57,6 +61,7 @@ export type NotifySilentTmuxSessionsParams = {
|
|
|
57
61
|
hubTaskStatusCacheStateFilePath: string | null;
|
|
58
62
|
hubTaskStatusCacheTtlSeconds: number;
|
|
59
63
|
messageTemplates: SilentSessionMessageTemplates;
|
|
64
|
+
submitPushOutWaitMilliseconds?: number;
|
|
60
65
|
now: Date;
|
|
61
66
|
};
|
|
62
67
|
|
|
@@ -121,6 +126,7 @@ export const notifySilentTmuxSessions = async (
|
|
|
121
126
|
hubTaskStatusCacheStateFilePath,
|
|
122
127
|
hubTaskStatusCacheTtlSeconds,
|
|
123
128
|
messageTemplates,
|
|
129
|
+
submitPushOutWaitMilliseconds,
|
|
124
130
|
now,
|
|
125
131
|
} = params;
|
|
126
132
|
if (!enabled) {
|
|
@@ -149,7 +155,13 @@ export const notifySilentTmuxSessions = async (
|
|
|
149
155
|
now,
|
|
150
156
|
),
|
|
151
157
|
createOwnerCallStatusProvider(ownerCallMarker),
|
|
152
|
-
new TmuxSilentSessionNotificationRepository(
|
|
158
|
+
new TmuxSilentSessionNotificationRepository(
|
|
159
|
+
localCommandRunner,
|
|
160
|
+
new RealSleeper(),
|
|
161
|
+
DEFAULT_SUBMIT_PUSH_OUT_ATTEMPT_LIMIT,
|
|
162
|
+
submitPushOutWaitMilliseconds ??
|
|
163
|
+
DEFAULT_SUBMIT_PUSH_OUT_WAIT_MILLISECONDS,
|
|
164
|
+
),
|
|
153
165
|
candidateDebounceStateFilePath !== null
|
|
154
166
|
? new FileSystemSilentSessionCandidateStateRepository(
|
|
155
167
|
candidateDebounceStateFilePath,
|
|
@@ -46,7 +46,10 @@ export const resetDegeneratedTmuxSessions = async (
|
|
|
46
46
|
),
|
|
47
47
|
new FileSystemInteractiveLiveSessionTranscriptResolver(),
|
|
48
48
|
new FileSystemSessionAssistantTurnsRepository(),
|
|
49
|
-
new TmuxSilentSessionNotificationRepository(
|
|
49
|
+
new TmuxSilentSessionNotificationRepository(
|
|
50
|
+
localCommandRunner,
|
|
51
|
+
new RealSleeper(),
|
|
52
|
+
),
|
|
50
53
|
new NodeTmuxSessionRepository(localCommandRunner),
|
|
51
54
|
cooldownStateFilePath !== null
|
|
52
55
|
? new FileSystemSessionDegenerationCooldownStateRepository(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
2
|
+
import { Sleeper } from '../../domain/usecases/adapter-interfaces/Sleeper';
|
|
2
3
|
import { TmuxSilentSessionNotificationRepository } from './TmuxSilentSessionNotificationRepository';
|
|
3
4
|
|
|
4
5
|
type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
|
|
@@ -11,14 +12,55 @@ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
|
|
|
11
12
|
}),
|
|
12
13
|
});
|
|
13
14
|
|
|
15
|
+
const createMockSleeper = (): Mocked<Sleeper> => ({
|
|
16
|
+
sleep: jest.fn().mockResolvedValue(undefined),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const sessionName = 'https_//github_com/owner/repo/issues/9';
|
|
20
|
+
|
|
21
|
+
const paneWithInputBoxContent = (inputBoxContent: string): string =>
|
|
22
|
+
[
|
|
23
|
+
'● The previous assistant output line',
|
|
24
|
+
'',
|
|
25
|
+
'────────────────────────────────────────',
|
|
26
|
+
inputBoxContent,
|
|
27
|
+
'────────────────────────────────────────',
|
|
28
|
+
' {Opus 4.5} (dev1) CTX[██░░░░░░░░]16%',
|
|
29
|
+
].join('\n');
|
|
30
|
+
|
|
31
|
+
const createPaneAwareRunner = (
|
|
32
|
+
paneTexts: string[],
|
|
33
|
+
): Mocked<LocalCommandRunner> => {
|
|
34
|
+
let capturedPaneIndex = 0;
|
|
35
|
+
return {
|
|
36
|
+
runCommand: jest.fn().mockImplementation((_program, args: string[]) => {
|
|
37
|
+
if (args[0] !== 'capture-pane') {
|
|
38
|
+
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 });
|
|
39
|
+
}
|
|
40
|
+
const paneText =
|
|
41
|
+
paneTexts[Math.min(capturedPaneIndex, paneTexts.length - 1)];
|
|
42
|
+
capturedPaneIndex += 1;
|
|
43
|
+
return Promise.resolve({ stdout: paneText, stderr: '', exitCode: 0 });
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const submitKeyCallCount = (runner: Mocked<LocalCommandRunner>): number =>
|
|
49
|
+
runner.runCommand.mock.calls.filter(
|
|
50
|
+
(call) => call[1][0] === 'send-keys' && call[1][3] === 'Enter',
|
|
51
|
+
).length;
|
|
52
|
+
|
|
14
53
|
describe('TmuxSilentSessionNotificationRepository', () => {
|
|
15
54
|
describe('sendSelfCheckNotification', () => {
|
|
16
55
|
it('wraps the message in bracketed-paste framing, sends it literally, then submits it with Enter', async () => {
|
|
17
56
|
const runner = createMockRunner();
|
|
18
|
-
const repository = new TmuxSilentSessionNotificationRepository(
|
|
57
|
+
const repository = new TmuxSilentSessionNotificationRepository(
|
|
58
|
+
runner,
|
|
59
|
+
createMockSleeper(),
|
|
60
|
+
);
|
|
19
61
|
|
|
20
62
|
await repository.sendSelfCheckNotification(
|
|
21
|
-
|
|
63
|
+
sessionName,
|
|
22
64
|
'self check message',
|
|
23
65
|
);
|
|
24
66
|
|
|
@@ -27,26 +69,26 @@ describe('TmuxSilentSessionNotificationRepository', () => {
|
|
|
27
69
|
[
|
|
28
70
|
'send-keys',
|
|
29
71
|
'-t',
|
|
30
|
-
|
|
72
|
+
sessionName,
|
|
31
73
|
'-l',
|
|
32
74
|
'\x1b[200~self check message\x1b[201~',
|
|
33
75
|
],
|
|
34
76
|
]);
|
|
35
77
|
expect(runner.runCommand.mock.calls[1]).toEqual([
|
|
36
78
|
'tmux',
|
|
37
|
-
['send-keys', '-t',
|
|
79
|
+
['send-keys', '-t', sessionName, 'Enter'],
|
|
38
80
|
]);
|
|
39
81
|
});
|
|
40
82
|
|
|
41
83
|
it('flattens a multi-line message into a single line so no newline reaches the input box', async () => {
|
|
42
84
|
const runner = createMockRunner();
|
|
43
|
-
const repository = new TmuxSilentSessionNotificationRepository(
|
|
85
|
+
const repository = new TmuxSilentSessionNotificationRepository(
|
|
86
|
+
runner,
|
|
87
|
+
createMockSleeper(),
|
|
88
|
+
);
|
|
44
89
|
const multiLineMessage = 'first line\nsecond line\n\nthird line';
|
|
45
90
|
|
|
46
|
-
await repository.sendSelfCheckNotification(
|
|
47
|
-
'https_//github_com/owner/repo/issues/9',
|
|
48
|
-
multiLineMessage,
|
|
49
|
-
);
|
|
91
|
+
await repository.sendSelfCheckNotification(sessionName, multiLineMessage);
|
|
50
92
|
|
|
51
93
|
const literalArgument = runner.runCommand.mock.calls[0][1][4];
|
|
52
94
|
expect(literalArgument).not.toContain('\n');
|
|
@@ -58,10 +100,13 @@ describe('TmuxSilentSessionNotificationRepository', () => {
|
|
|
58
100
|
|
|
59
101
|
it('keeps the bracketed-paste framing around the flattened message', async () => {
|
|
60
102
|
const runner = createMockRunner();
|
|
61
|
-
const repository = new TmuxSilentSessionNotificationRepository(
|
|
103
|
+
const repository = new TmuxSilentSessionNotificationRepository(
|
|
104
|
+
runner,
|
|
105
|
+
createMockSleeper(),
|
|
106
|
+
);
|
|
62
107
|
|
|
63
108
|
await repository.sendSelfCheckNotification(
|
|
64
|
-
|
|
109
|
+
sessionName,
|
|
65
110
|
'first line\nsecond line',
|
|
66
111
|
);
|
|
67
112
|
|
|
@@ -71,16 +116,19 @@ describe('TmuxSilentSessionNotificationRepository', () => {
|
|
|
71
116
|
expect(literalBytes.endsWith('1b5b3230317e')).toBe(true);
|
|
72
117
|
expect(runner.runCommand.mock.calls[1]).toEqual([
|
|
73
118
|
'tmux',
|
|
74
|
-
['send-keys', '-t',
|
|
119
|
+
['send-keys', '-t', sessionName, 'Enter'],
|
|
75
120
|
]);
|
|
76
121
|
});
|
|
77
122
|
|
|
78
123
|
it('collapses the run of spaces left by consecutive line breaks', async () => {
|
|
79
124
|
const runner = createMockRunner();
|
|
80
|
-
const repository = new TmuxSilentSessionNotificationRepository(
|
|
125
|
+
const repository = new TmuxSilentSessionNotificationRepository(
|
|
126
|
+
runner,
|
|
127
|
+
createMockSleeper(),
|
|
128
|
+
);
|
|
81
129
|
|
|
82
130
|
await repository.sendSelfCheckNotification(
|
|
83
|
-
|
|
131
|
+
sessionName,
|
|
84
132
|
'section one\n\n\nsection two',
|
|
85
133
|
);
|
|
86
134
|
|
|
@@ -97,7 +145,10 @@ describe('TmuxSilentSessionNotificationRepository', () => {
|
|
|
97
145
|
stderr: "can't find session",
|
|
98
146
|
exitCode: 1,
|
|
99
147
|
});
|
|
100
|
-
const repository = new TmuxSilentSessionNotificationRepository(
|
|
148
|
+
const repository = new TmuxSilentSessionNotificationRepository(
|
|
149
|
+
runner,
|
|
150
|
+
createMockSleeper(),
|
|
151
|
+
);
|
|
101
152
|
|
|
102
153
|
await expect(
|
|
103
154
|
repository.sendSelfCheckNotification('missing_session', 'message'),
|
|
@@ -105,5 +156,96 @@ describe('TmuxSilentSessionNotificationRepository', () => {
|
|
|
105
156
|
'Failed to send notification to tmux session "missing_session"',
|
|
106
157
|
);
|
|
107
158
|
});
|
|
159
|
+
|
|
160
|
+
it('pushes the message out with another Enter while it is still sitting in the input box', async () => {
|
|
161
|
+
const runner = createPaneAwareRunner([
|
|
162
|
+
paneWithInputBoxContent(
|
|
163
|
+
'❯ [TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
|
|
164
|
+
),
|
|
165
|
+
paneWithInputBoxContent('❯ '),
|
|
166
|
+
]);
|
|
167
|
+
const repository = new TmuxSilentSessionNotificationRepository(
|
|
168
|
+
runner,
|
|
169
|
+
createMockSleeper(),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
await repository.sendSelfCheckNotification(
|
|
173
|
+
sessionName,
|
|
174
|
+
'[TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect(submitKeyCallCount(runner)).toBe(2);
|
|
178
|
+
expect(runner.runCommand.mock.calls[2]).toEqual([
|
|
179
|
+
'tmux',
|
|
180
|
+
['capture-pane', '-p', '-t', sessionName],
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('sends no further Enter once the input box no longer holds the message', async () => {
|
|
185
|
+
const runner = createPaneAwareRunner([paneWithInputBoxContent('❯ ')]);
|
|
186
|
+
const repository = new TmuxSilentSessionNotificationRepository(
|
|
187
|
+
runner,
|
|
188
|
+
createMockSleeper(),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
await repository.sendSelfCheckNotification(
|
|
192
|
+
sessionName,
|
|
193
|
+
'[TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
expect(submitKeyCallCount(runner)).toBe(1);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('sends no further Enter while the message is held as a queued message', async () => {
|
|
200
|
+
const runner = createPaneAwareRunner([
|
|
201
|
+
paneWithInputBoxContent('❯ Press up to edit queued messages'),
|
|
202
|
+
]);
|
|
203
|
+
const repository = new TmuxSilentSessionNotificationRepository(
|
|
204
|
+
runner,
|
|
205
|
+
createMockSleeper(),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
await repository.sendSelfCheckNotification(
|
|
209
|
+
sessionName,
|
|
210
|
+
'[TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(submitKeyCallCount(runner)).toBe(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('stops pushing after the configured attempt limit so newlines cannot pile up', async () => {
|
|
217
|
+
const runner = createPaneAwareRunner([
|
|
218
|
+
paneWithInputBoxContent(
|
|
219
|
+
'❯ [TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
|
|
220
|
+
),
|
|
221
|
+
]);
|
|
222
|
+
const repository = new TmuxSilentSessionNotificationRepository(
|
|
223
|
+
runner,
|
|
224
|
+
createMockSleeper(),
|
|
225
|
+
2,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
await repository.sendSelfCheckNotification(
|
|
229
|
+
sessionName,
|
|
230
|
+
'[TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
expect(submitKeyCallCount(runner)).toBe(3);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('waits for the bracketed-paste parsing and rendering window before reading the input box back', async () => {
|
|
237
|
+
const runner = createPaneAwareRunner([paneWithInputBoxContent('❯ ')]);
|
|
238
|
+
const sleeper = createMockSleeper();
|
|
239
|
+
const repository = new TmuxSilentSessionNotificationRepository(
|
|
240
|
+
runner,
|
|
241
|
+
sleeper,
|
|
242
|
+
3,
|
|
243
|
+
2500,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
await repository.sendSelfCheckNotification(sessionName, 'self check');
|
|
247
|
+
|
|
248
|
+
expect(sleeper.sleep).toHaveBeenCalledWith(2500);
|
|
249
|
+
});
|
|
108
250
|
});
|
|
109
251
|
});
|