github-issue-tower-defence-management 1.146.7 → 1.146.8
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 +7 -0
- package/README.md +5 -4
- package/bin/adapter/entry-points/cli/fleetConfig.js +101 -0
- package/bin/adapter/entry-points/cli/fleetConfig.js.map +1 -0
- package/bin/adapter/entry-points/cli/index.js +5 -2
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +4 -5
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -1
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +24 -9
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/fleetConfig.test.ts +177 -0
- package/src/adapter/entry-points/cli/fleetConfig.ts +109 -0
- package/src/adapter/entry-points/cli/index.ts +14 -2
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +25 -10
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +5 -5
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +325 -156
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +50 -22
- package/types/adapter/entry-points/cli/fleetConfig.d.ts +6 -0
- package/types/adapter/entry-points/cli/fleetConfig.d.ts.map +1 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts +3 -4
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -1
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +10 -2
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS } from '../../../domain/usecases/LiveSessionOauthTokenSelectUseCase';
|
|
5
|
+
import {
|
|
6
|
+
FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE,
|
|
7
|
+
loadLiveSessionOauthTokenSelectionSettings,
|
|
8
|
+
resolveFleetConfigFilePath,
|
|
9
|
+
} from './fleetConfig';
|
|
10
|
+
|
|
11
|
+
describe('resolveFleetConfigFilePath', () => {
|
|
12
|
+
const originalEnvironmentValue =
|
|
13
|
+
process.env[FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE];
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
if (originalEnvironmentValue === undefined) {
|
|
17
|
+
delete process.env[FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE];
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
process.env[FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE] =
|
|
21
|
+
originalEnvironmentValue;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('prefers the explicit value over the environment variable', () => {
|
|
25
|
+
process.env[FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE] =
|
|
26
|
+
'/from/environment.yaml';
|
|
27
|
+
|
|
28
|
+
expect(resolveFleetConfigFilePath('/from/option.yaml')).toBe(
|
|
29
|
+
'/from/option.yaml',
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('falls back to the environment variable when no explicit value is given', () => {
|
|
34
|
+
process.env[FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE] =
|
|
35
|
+
'/from/environment.yaml';
|
|
36
|
+
|
|
37
|
+
expect(resolveFleetConfigFilePath(null)).toBe('/from/environment.yaml');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns null when neither an explicit value nor the environment variable is set', () => {
|
|
41
|
+
delete process.env[FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE];
|
|
42
|
+
|
|
43
|
+
expect(resolveFleetConfigFilePath(null)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('loadLiveSessionOauthTokenSelectionSettings', () => {
|
|
48
|
+
let tempDir: string;
|
|
49
|
+
|
|
50
|
+
const writeFleetConfig = (content: string): string => {
|
|
51
|
+
const fleetConfigFilePath = path.join(tempDir, 'fleet.config.yaml');
|
|
52
|
+
fs.writeFileSync(fleetConfigFilePath, content);
|
|
53
|
+
return fleetConfigFilePath;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fleet-config-'));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns the built-in settings when no fleet config path is given', () => {
|
|
65
|
+
expect(loadLiveSessionOauthTokenSelectionSettings(null)).toEqual(
|
|
66
|
+
DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('reads both tuning numbers from the fleet config file', () => {
|
|
71
|
+
const fleetConfigFilePath = writeFleetConfig(
|
|
72
|
+
[
|
|
73
|
+
'inTmuxLauncherCommand: cl',
|
|
74
|
+
'liveSessionOauthTokenSelection:',
|
|
75
|
+
' maxConcurrentSessionCount: 16',
|
|
76
|
+
' fullSpeedFiveHourFreeRatio: 0.4',
|
|
77
|
+
].join('\n'),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(
|
|
81
|
+
loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
|
|
82
|
+
).toEqual({
|
|
83
|
+
maxConcurrentSessionCount: 16,
|
|
84
|
+
fullSpeedFiveHourFreeRatio: 0.4,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('keeps the built-in value for a tuning number the fleet config omits', () => {
|
|
89
|
+
const fleetConfigFilePath = writeFleetConfig(
|
|
90
|
+
[
|
|
91
|
+
'liveSessionOauthTokenSelection:',
|
|
92
|
+
' maxConcurrentSessionCount: 16',
|
|
93
|
+
].join('\n'),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(
|
|
97
|
+
loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
|
|
98
|
+
).toEqual({
|
|
99
|
+
maxConcurrentSessionCount: 16,
|
|
100
|
+
fullSpeedFiveHourFreeRatio:
|
|
101
|
+
DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS.fullSpeedFiveHourFreeRatio,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns the built-in settings when the fleet config carries no live session section', () => {
|
|
106
|
+
const fleetConfigFilePath = writeFleetConfig('inTmuxLauncherCommand: cl\n');
|
|
107
|
+
|
|
108
|
+
expect(
|
|
109
|
+
loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
|
|
110
|
+
).toEqual(DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns the built-in settings for an empty fleet config file', () => {
|
|
114
|
+
const fleetConfigFilePath = writeFleetConfig('');
|
|
115
|
+
|
|
116
|
+
expect(
|
|
117
|
+
loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
|
|
118
|
+
).toEqual(DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('throws when the fleet config file does not exist', () => {
|
|
122
|
+
expect(() =>
|
|
123
|
+
loadLiveSessionOauthTokenSelectionSettings(
|
|
124
|
+
path.join(tempDir, 'missing.yaml'),
|
|
125
|
+
),
|
|
126
|
+
).toThrow();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('throws when the maximum concurrent session count is not a positive integer', () => {
|
|
130
|
+
const fleetConfigFilePath = writeFleetConfig(
|
|
131
|
+
[
|
|
132
|
+
'liveSessionOauthTokenSelection:',
|
|
133
|
+
' maxConcurrentSessionCount: 0',
|
|
134
|
+
].join('\n'),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(() =>
|
|
138
|
+
loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
|
|
139
|
+
).toThrow('maxConcurrentSessionCount');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('throws when the five hour free ratio is above one', () => {
|
|
143
|
+
const fleetConfigFilePath = writeFleetConfig(
|
|
144
|
+
[
|
|
145
|
+
'liveSessionOauthTokenSelection:',
|
|
146
|
+
' fullSpeedFiveHourFreeRatio: 1.5',
|
|
147
|
+
].join('\n'),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(() =>
|
|
151
|
+
loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
|
|
152
|
+
).toThrow('fullSpeedFiveHourFreeRatio');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('throws when a tuning number is written as a string', () => {
|
|
156
|
+
const fleetConfigFilePath = writeFleetConfig(
|
|
157
|
+
[
|
|
158
|
+
'liveSessionOauthTokenSelection:',
|
|
159
|
+
" maxConcurrentSessionCount: '16'",
|
|
160
|
+
].join('\n'),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(() =>
|
|
164
|
+
loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
|
|
165
|
+
).toThrow('must be a number');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('throws when the live session section is not a mapping', () => {
|
|
169
|
+
const fleetConfigFilePath = writeFleetConfig(
|
|
170
|
+
'liveSessionOauthTokenSelection: 10\n',
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
expect(() =>
|
|
174
|
+
loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
|
|
175
|
+
).toThrow('must be a mapping');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import YAML from 'yaml';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
|
|
5
|
+
LiveSessionOauthTokenSelectionSettings,
|
|
6
|
+
} from '../../../domain/usecases/LiveSessionOauthTokenSelectUseCase';
|
|
7
|
+
|
|
8
|
+
export const FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE = 'TDPM_FLEET_CONFIG';
|
|
9
|
+
|
|
10
|
+
export const LIVE_SESSION_OAUTH_TOKEN_SELECTION_SECTION_KEY =
|
|
11
|
+
'liveSessionOauthTokenSelection';
|
|
12
|
+
|
|
13
|
+
export const resolveFleetConfigFilePath = (
|
|
14
|
+
cliValue: string | null,
|
|
15
|
+
): string | null => {
|
|
16
|
+
if (cliValue !== null && cliValue !== '') {
|
|
17
|
+
return cliValue;
|
|
18
|
+
}
|
|
19
|
+
const fromEnvironment =
|
|
20
|
+
process.env[FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE];
|
|
21
|
+
if (fromEnvironment !== undefined && fromEnvironment !== '') {
|
|
22
|
+
return fromEnvironment;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
28
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
29
|
+
|
|
30
|
+
const readSection = (
|
|
31
|
+
fleetConfigFilePath: string,
|
|
32
|
+
): Record<string, unknown> | null => {
|
|
33
|
+
const parsed: unknown = YAML.parse(
|
|
34
|
+
fs.readFileSync(fleetConfigFilePath, 'utf8'),
|
|
35
|
+
);
|
|
36
|
+
if (parsed === null || parsed === undefined) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (!isRecord(parsed)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`${fleetConfigFilePath} does not hold a mapping at its top level.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const section = parsed[LIVE_SESSION_OAUTH_TOKEN_SELECTION_SECTION_KEY];
|
|
45
|
+
if (section === undefined || section === null) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (!isRecord(section)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`${LIVE_SESSION_OAUTH_TOKEN_SELECTION_SECTION_KEY} in ${fleetConfigFilePath} must be a mapping.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return section;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const readBoundedNumber = (
|
|
57
|
+
section: Record<string, unknown>,
|
|
58
|
+
key: string,
|
|
59
|
+
fleetConfigFilePath: string,
|
|
60
|
+
fallback: number,
|
|
61
|
+
isAccepted: (value: number) => boolean,
|
|
62
|
+
requirement: string,
|
|
63
|
+
): number => {
|
|
64
|
+
const value = section[key];
|
|
65
|
+
if (value === undefined || value === null) {
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`${LIVE_SESSION_OAUTH_TOKEN_SELECTION_SECTION_KEY}.${key} in ${fleetConfigFilePath} must be a number ${requirement}.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (!isAccepted(value)) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`${LIVE_SESSION_OAUTH_TOKEN_SELECTION_SECTION_KEY}.${key} in ${fleetConfigFilePath} must be a number ${requirement}, but it is ${value}.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const loadLiveSessionOauthTokenSelectionSettings = (
|
|
82
|
+
fleetConfigFilePath: string | null,
|
|
83
|
+
): LiveSessionOauthTokenSelectionSettings => {
|
|
84
|
+
if (fleetConfigFilePath === null) {
|
|
85
|
+
return DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS;
|
|
86
|
+
}
|
|
87
|
+
const section = readSection(fleetConfigFilePath);
|
|
88
|
+
if (section === null) {
|
|
89
|
+
return DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
maxConcurrentSessionCount: readBoundedNumber(
|
|
93
|
+
section,
|
|
94
|
+
'maxConcurrentSessionCount',
|
|
95
|
+
fleetConfigFilePath,
|
|
96
|
+
DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS.maxConcurrentSessionCount,
|
|
97
|
+
(value) => Number.isInteger(value) && value >= 1,
|
|
98
|
+
'integer of at least 1',
|
|
99
|
+
),
|
|
100
|
+
fullSpeedFiveHourFreeRatio: readBoundedNumber(
|
|
101
|
+
section,
|
|
102
|
+
'fullSpeedFiveHourFreeRatio',
|
|
103
|
+
fleetConfigFilePath,
|
|
104
|
+
DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS.fullSpeedFiveHourFreeRatio,
|
|
105
|
+
(value) => value > 0 && value <= 1,
|
|
106
|
+
'above 0 and at most 1',
|
|
107
|
+
),
|
|
108
|
+
};
|
|
109
|
+
};
|
|
@@ -59,6 +59,10 @@ import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/Iss
|
|
|
59
59
|
import { OauthTokenSelectHandler } from '../handlers/OauthTokenSelectHandler';
|
|
60
60
|
import { LiveSessionOauthTokenSelectHandler } from '../handlers/LiveSessionOauthTokenSelectHandler';
|
|
61
61
|
import { InTmuxByHumanSessionTokenCountHandler } from '../handlers/InTmuxByHumanSessionTokenCountHandler';
|
|
62
|
+
import {
|
|
63
|
+
loadLiveSessionOauthTokenSelectionSettings,
|
|
64
|
+
resolveFleetConfigFilePath,
|
|
65
|
+
} from './fleetConfig';
|
|
62
66
|
|
|
63
67
|
type StartDaemonOptions = {
|
|
64
68
|
projectUrl?: string;
|
|
@@ -127,6 +131,7 @@ type SelectOauthTokenOptions = {
|
|
|
127
131
|
type SelectLiveSessionOauthTokenOptions = {
|
|
128
132
|
tokenListJsonPath?: string;
|
|
129
133
|
cacheDir?: string;
|
|
134
|
+
fleetConfigFilePath?: string;
|
|
130
135
|
};
|
|
131
136
|
|
|
132
137
|
type CountInTmuxByHumanSessionsPerTokenOptions = {
|
|
@@ -951,22 +956,29 @@ program
|
|
|
951
956
|
program
|
|
952
957
|
.command('selectLiveSessionOauthToken')
|
|
953
958
|
.description(
|
|
954
|
-
'Print exactly one Claude Code OAuth token chosen for a new live interactive session.
|
|
959
|
+
'Print exactly one Claude Code OAuth token chosen for a new live interactive session. The choice is deterministic. Each rate-limit-eligible token gets a concurrent session limit of maxConcurrentSessionCount (default 10), scaled by its per-token selectionWeight (default 1). That limit is held at full value while the free share of the 5h window is at or above fullSpeedFiveHourFreeRatio (default 0.5) and is tapered in proportion below it, never dropping under 1; the 7d window never lowers the limit, so a weekly allowance that is about to expire is drained at full speed instead of being discarded unused. Among eligible tokens still under that limit the token whose 7d window resets soonest wins; ties go to the token carrying fewer live sessions (by distinct CLAUDE_CODE_SESSION_ID found in running Claude Code processes). When every eligible token is at its limit the soonest-resetting one is still returned. The token string is written to stdout (pipeable); the per-candidate decision trace is written to stderr. Exits non-zero when no token passes the filter.',
|
|
955
960
|
)
|
|
956
961
|
.option(
|
|
957
962
|
'--tokenListJsonPath <path>',
|
|
958
|
-
'Path to the JSON array of { name, token, selectionWeight? } records. selectionWeight is an optional positive number (default 1) that
|
|
963
|
+
'Path to the JSON array of { name, token, selectionWeight? } records. selectionWeight is an optional positive number (default 1) that scales this token concurrent live session limit; a smaller weight allows fewer simultaneous sessions and never bypasses eligibility filtering or starves a sole eligible token. Falls back to the CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH environment variable.',
|
|
959
964
|
)
|
|
960
965
|
.option(
|
|
961
966
|
'--cacheDir <path>',
|
|
962
967
|
'Directory holding per-token rate-limit cache files. Falls back to the TDPM_RATELIMIT_CACHE_DIR environment variable, then to ${XDG_CACHE_HOME:-~/.cache}/tdpm/ratelimit.',
|
|
963
968
|
)
|
|
969
|
+
.option(
|
|
970
|
+
'--fleetConfigFilePath <path>',
|
|
971
|
+
'Path to the fleet-wide YAML config file holding the liveSessionOauthTokenSelection mapping (maxConcurrentSessionCount, fullSpeedFiveHourFreeRatio). Falls back to the TDPM_FLEET_CONFIG environment variable; when neither is set the built-in values are used. A key the file omits keeps its built-in value, and an unreadable file or an out-of-range value is reported as an error instead of being ignored.',
|
|
972
|
+
)
|
|
964
973
|
.action((options: SelectLiveSessionOauthTokenOptions) => {
|
|
965
974
|
const handler = new LiveSessionOauthTokenSelectHandler();
|
|
966
975
|
const output = handler.handle({
|
|
967
976
|
tokenListJsonPath: options.tokenListJsonPath ?? null,
|
|
968
977
|
cacheDirectory: options.cacheDir ?? null,
|
|
969
978
|
nowEpochSeconds: Date.now() / 1000,
|
|
979
|
+
selectionSettings: loadLiveSessionOauthTokenSelectionSettings(
|
|
980
|
+
resolveFleetConfigFilePath(options.fleetConfigFilePath ?? null),
|
|
981
|
+
),
|
|
970
982
|
});
|
|
971
983
|
|
|
972
984
|
for (const line of output.diagnostics) {
|
|
@@ -5,7 +5,10 @@ import {
|
|
|
5
5
|
ClaudeLiveSession,
|
|
6
6
|
ClaudeLiveSessionRepository,
|
|
7
7
|
} from '../../../domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
|
|
10
|
+
LiveSessionOauthTokenSelectUseCase,
|
|
11
|
+
} from '../../../domain/usecases/LiveSessionOauthTokenSelectUseCase';
|
|
9
12
|
import { FABLE_LIMIT_TYPE, hashToken } from '../../proxy/RateLimitCache';
|
|
10
13
|
import { LiveSessionOauthTokenSelectHandler } from './LiveSessionOauthTokenSelectHandler';
|
|
11
14
|
|
|
@@ -109,12 +112,10 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
109
112
|
|
|
110
113
|
const buildHandler = (
|
|
111
114
|
sessions: ClaudeLiveSession[],
|
|
112
|
-
random: () => number = () => 0.5,
|
|
113
115
|
): LiveSessionOauthTokenSelectHandler =>
|
|
114
116
|
new LiveSessionOauthTokenSelectHandler(
|
|
115
117
|
new LiveSessionOauthTokenSelectUseCase(),
|
|
116
118
|
new FakeClaudeLiveSessionRepository(sessions),
|
|
117
|
-
random,
|
|
118
119
|
);
|
|
119
120
|
|
|
120
121
|
const writeFableRejectionCache = (token: string, resetsAt: number): void => {
|
|
@@ -157,6 +158,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
157
158
|
tokenListJsonPath: tokenListPath,
|
|
158
159
|
cacheDirectory,
|
|
159
160
|
nowEpochSeconds: NOW,
|
|
161
|
+
selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
|
|
160
162
|
});
|
|
161
163
|
|
|
162
164
|
expect(output.selectedName).toBe('active');
|
|
@@ -165,7 +167,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
165
167
|
);
|
|
166
168
|
});
|
|
167
169
|
|
|
168
|
-
it('selects the eligible token
|
|
170
|
+
it('selects the soonest resetting eligible token even when it already carries a live session', () => {
|
|
169
171
|
writeTokenList([
|
|
170
172
|
{ name: 'busy', token: 'fake-busy' },
|
|
171
173
|
{ name: 'idle', token: 'fake-idle' },
|
|
@@ -183,18 +185,21 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
183
185
|
sevenDayReset: NOW + 6 * DAY,
|
|
184
186
|
});
|
|
185
187
|
|
|
186
|
-
const handler = buildHandler(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
);
|
|
188
|
+
const handler = buildHandler([
|
|
189
|
+
{ token: 'fake-busy', sessionKey: 'session-a' },
|
|
190
|
+
]);
|
|
190
191
|
const output = handler.handle({
|
|
191
192
|
tokenListJsonPath: tokenListPath,
|
|
192
193
|
cacheDirectory,
|
|
193
194
|
nowEpochSeconds: NOW,
|
|
195
|
+
selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
|
|
194
196
|
});
|
|
195
197
|
|
|
196
|
-
expect(output.selectedName).toBe('
|
|
197
|
-
expect(output.selectedToken).toBe('fake-
|
|
198
|
+
expect(output.selectedName).toBe('busy');
|
|
199
|
+
expect(output.selectedToken).toBe('fake-busy');
|
|
200
|
+
expect(output.diagnostics.join('\n')).toContain(
|
|
201
|
+
'busy: 1/10 live session(s)',
|
|
202
|
+
);
|
|
198
203
|
});
|
|
199
204
|
|
|
200
205
|
it('breaks an occupancy tie by the soonest 7d reset', () => {
|
|
@@ -223,6 +228,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
223
228
|
tokenListJsonPath: tokenListPath,
|
|
224
229
|
cacheDirectory,
|
|
225
230
|
nowEpochSeconds: NOW,
|
|
231
|
+
selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
|
|
226
232
|
});
|
|
227
233
|
|
|
228
234
|
expect(output.selectedName).toBe('soon');
|
|
@@ -256,6 +262,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
256
262
|
tokenListJsonPath: tokenListPath,
|
|
257
263
|
cacheDirectory,
|
|
258
264
|
nowEpochSeconds: NOW,
|
|
265
|
+
selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
|
|
259
266
|
});
|
|
260
267
|
|
|
261
268
|
expect(output.selectedName).toBe('oneSession');
|
|
@@ -286,6 +293,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
286
293
|
tokenListJsonPath: tokenListPath,
|
|
287
294
|
cacheDirectory,
|
|
288
295
|
nowEpochSeconds: NOW,
|
|
296
|
+
selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
|
|
289
297
|
});
|
|
290
298
|
|
|
291
299
|
expect(output.selectedName).toBe('free');
|
|
@@ -305,6 +313,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
305
313
|
tokenListJsonPath: tokenListPath,
|
|
306
314
|
cacheDirectory,
|
|
307
315
|
nowEpochSeconds: NOW,
|
|
316
|
+
selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
|
|
308
317
|
});
|
|
309
318
|
|
|
310
319
|
expect(output.selectedToken).toBeNull();
|
|
@@ -330,6 +339,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
330
339
|
tokenListJsonPath: tokenListPath,
|
|
331
340
|
cacheDirectory,
|
|
332
341
|
nowEpochSeconds: NOW,
|
|
342
|
+
selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
|
|
333
343
|
});
|
|
334
344
|
|
|
335
345
|
expect(output.selectedToken).toBe('fake-free');
|
|
@@ -343,6 +353,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
343
353
|
tokenListJsonPath: null,
|
|
344
354
|
cacheDirectory,
|
|
345
355
|
nowEpochSeconds: NOW,
|
|
356
|
+
selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
|
|
346
357
|
});
|
|
347
358
|
|
|
348
359
|
expect(output.selectedToken).toBeNull();
|
|
@@ -357,6 +368,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
357
368
|
tokenListJsonPath: tokenListPath,
|
|
358
369
|
cacheDirectory,
|
|
359
370
|
nowEpochSeconds: NOW,
|
|
371
|
+
selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
|
|
360
372
|
});
|
|
361
373
|
|
|
362
374
|
expect(output.selectedToken).toBeNull();
|
|
@@ -383,6 +395,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
383
395
|
tokenListJsonPath: tokenListPath,
|
|
384
396
|
cacheDirectory,
|
|
385
397
|
nowEpochSeconds: NOW,
|
|
398
|
+
selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
|
|
386
399
|
});
|
|
387
400
|
|
|
388
401
|
expect(output.selectedName).toBe('active');
|
|
@@ -419,6 +432,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
419
432
|
tokenListJsonPath: tokenListPath,
|
|
420
433
|
cacheDirectory,
|
|
421
434
|
nowEpochSeconds: NOW,
|
|
435
|
+
selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
|
|
422
436
|
});
|
|
423
437
|
|
|
424
438
|
expect(output.selectedName).toBe('active');
|
|
@@ -446,6 +460,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
|
|
|
446
460
|
tokenListJsonPath: tokenListPath,
|
|
447
461
|
cacheDirectory,
|
|
448
462
|
nowEpochSeconds: NOW,
|
|
463
|
+
selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
|
|
449
464
|
});
|
|
450
465
|
|
|
451
466
|
expect(output.selectedToken).toBeNull();
|
|
@@ -2,13 +2,13 @@ import { ClaudeLiveSessionRepository } from '../../../domain/usecases/adapter-in
|
|
|
2
2
|
import {
|
|
3
3
|
LiveSessionOauthTokenSelectResult,
|
|
4
4
|
LiveSessionOauthTokenSelectUseCase,
|
|
5
|
+
LiveSessionOauthTokenSelectionSettings,
|
|
5
6
|
} from '../../../domain/usecases/LiveSessionOauthTokenSelectUseCase';
|
|
6
7
|
import {
|
|
7
8
|
DEFAULT_SELECTION_WEIGHT,
|
|
8
9
|
FIVE_HOUR_MIN_FREE_RATIO,
|
|
9
10
|
OauthTokenCandidate,
|
|
10
11
|
SEVEN_DAY_MIN_FREE_RATIO,
|
|
11
|
-
SelectionRandom,
|
|
12
12
|
} from '../../../domain/usecases/OauthTokenSelectUseCase';
|
|
13
13
|
import { ProcClaudeLiveSessionRepository } from '../../repositories/ProcClaudeLiveSessionRepository';
|
|
14
14
|
import { FABLE_LIMIT_TYPE, readRateLimit } from '../../proxy/RateLimitCache';
|
|
@@ -22,6 +22,7 @@ export type LiveSessionOauthTokenSelectHandlerInput = {
|
|
|
22
22
|
tokenListJsonPath: string | null;
|
|
23
23
|
cacheDirectory: string | null;
|
|
24
24
|
nowEpochSeconds: number;
|
|
25
|
+
selectionSettings: LiveSessionOauthTokenSelectionSettings;
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
export type LiveSessionOauthTokenSelectHandlerOutput = {
|
|
@@ -34,7 +35,6 @@ export class LiveSessionOauthTokenSelectHandler {
|
|
|
34
35
|
constructor(
|
|
35
36
|
private readonly useCase: LiveSessionOauthTokenSelectUseCase = new LiveSessionOauthTokenSelectUseCase(),
|
|
36
37
|
private readonly liveSessionRepository: ClaudeLiveSessionRepository = new ProcClaudeLiveSessionRepository(),
|
|
37
|
-
private readonly random: SelectionRandom = Math.random,
|
|
38
38
|
) {}
|
|
39
39
|
|
|
40
40
|
handle = (
|
|
@@ -98,7 +98,7 @@ export class LiveSessionOauthTokenSelectHandler {
|
|
|
98
98
|
candidates,
|
|
99
99
|
liveSessions,
|
|
100
100
|
input.nowEpochSeconds,
|
|
101
|
-
|
|
101
|
+
input.selectionSettings,
|
|
102
102
|
);
|
|
103
103
|
|
|
104
104
|
return {
|
|
@@ -119,7 +119,7 @@ export class LiveSessionOauthTokenSelectHandler {
|
|
|
119
119
|
const status = metric.eligible
|
|
120
120
|
? 'eligible'
|
|
121
121
|
: `excluded (${metric.exclusionReason})`;
|
|
122
|
-
return `${metric.name}: ${metric.liveSessionCount} live session(s), 5h ${Math.round(metric.fiveHourFreeRatio * 100)}% free, 7d ${Math.round(metric.sevenDayFreeRatio * 100)}% free, 7d-end in ${secondsUntilSevenDayEnd}s -> ${status}`;
|
|
122
|
+
return `${metric.name}: ${metric.liveSessionCount}/${metric.concurrentSessionLimit} live session(s), 5h ${Math.round(metric.fiveHourFreeRatio * 100)}% free, 7d ${Math.round(metric.sevenDayFreeRatio * 100)}% free, 7d-end in ${secondsUntilSevenDayEnd}s -> ${status}`;
|
|
123
123
|
});
|
|
124
124
|
|
|
125
125
|
if (result.selected === null) {
|
|
@@ -128,7 +128,7 @@ export class LiveSessionOauthTokenSelectHandler {
|
|
|
128
128
|
);
|
|
129
129
|
} else {
|
|
130
130
|
lines.push(
|
|
131
|
-
`Selected ${result.selected.name} (
|
|
131
|
+
`Selected ${result.selected.name} (the soonest-resetting 7d window among tokens still under their concurrent session limit, which is set by the free share of the 5h window alone).`,
|
|
132
132
|
);
|
|
133
133
|
}
|
|
134
134
|
|