github-issue-tower-defence-management 1.69.4 → 1.69.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.
Files changed (49) hide show
  1. package/.github/CODEOWNERS +5 -0
  2. package/.github/copilot-instructions.md +6 -0
  3. package/.github/dependabot.yml +17 -0
  4. package/.github/instructions/code-review.instructions.md +7 -0
  5. package/.github/workflows/api-created_issue_pr.yml +6 -1
  6. package/.github/workflows/commit-lint.yml +5 -0
  7. package/.github/workflows/configs/commitlint.config.js +5 -0
  8. package/.github/workflows/create-pr.yml +6 -1
  9. package/.github/workflows/secret-scan.yml +51 -0
  10. package/.github/workflows/umino-project.yml +16 -3
  11. package/.gitleaks.toml +9 -0
  12. package/.prettierignore +1 -0
  13. package/CHANGELOG.md +14 -0
  14. package/README.md +1 -1
  15. package/bin/adapter/entry-points/cli/index.js +2 -2
  16. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  17. package/bin/adapter/entry-points/cli/projectConfig.js +24 -1
  18. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  19. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +16 -1
  20. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  21. package/bin/adapter/proxy/RateLimitCache.js +3 -0
  22. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  23. package/bin/adapter/repositories/ProxyRateLimitCacheRepository.js +2 -1
  24. package/bin/adapter/repositories/ProxyRateLimitCacheRepository.js.map +1 -1
  25. package/bin/domain/usecases/UpdateRateLimitCacheUseCase.js +5 -1
  26. package/bin/domain/usecases/UpdateRateLimitCacheUseCase.js.map +1 -1
  27. package/package.json +1 -1
  28. package/renovate.json +5 -0
  29. package/src/adapter/entry-points/cli/index.test.ts +102 -0
  30. package/src/adapter/entry-points/cli/index.ts +2 -2
  31. package/src/adapter/entry-points/cli/projectConfig.ts +30 -1
  32. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +94 -0
  33. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +47 -1
  34. package/src/adapter/proxy/RateLimitCache.test.ts +43 -0
  35. package/src/adapter/proxy/RateLimitCache.ts +4 -0
  36. package/src/adapter/repositories/ProxyRateLimitCacheRepository.test.ts +21 -6
  37. package/src/adapter/repositories/ProxyRateLimitCacheRepository.ts +2 -1
  38. package/src/domain/usecases/UpdateRateLimitCacheUseCase.test.ts +108 -11
  39. package/src/domain/usecases/UpdateRateLimitCacheUseCase.ts +7 -1
  40. package/src/domain/usecases/adapter-interfaces/RateLimitCacheRepository.ts +1 -0
  41. package/types/adapter/entry-points/cli/projectConfig.d.ts +1 -1
  42. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  43. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  44. package/types/adapter/proxy/RateLimitCache.d.ts +1 -0
  45. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  46. package/types/adapter/repositories/ProxyRateLimitCacheRepository.d.ts.map +1 -1
  47. package/types/domain/usecases/UpdateRateLimitCacheUseCase.d.ts.map +1 -1
  48. package/types/domain/usecases/adapter-interfaces/RateLimitCacheRepository.d.ts +1 -0
  49. package/types/domain/usecases/adapter-interfaces/RateLimitCacheRepository.d.ts.map +1 -1
@@ -350,6 +350,108 @@ codexHomeCandidates:
350
350
  '.codex-main',
351
351
  ]);
352
352
  });
353
+
354
+ it('should not warn when only known top-level keys are present', () => {
355
+ const readme = `<details>
356
+ <summary>config</summary>
357
+ defaultAgentName: 'agent'
358
+ maximumPreparingIssuesCount: 5
359
+ </details>`;
360
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
361
+
362
+ const result = parseProjectReadmeConfig(
363
+ readme,
364
+ 'https://github.com/orgs/example/projects/1',
365
+ );
366
+
367
+ expect(result.defaultAgentName).toBe('agent');
368
+ expect(result.maximumPreparingIssuesCount).toBe(5);
369
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
370
+
371
+ consoleWarnSpy.mockRestore();
372
+ });
373
+
374
+ it('should warn for each unknown top-level key with project URL', () => {
375
+ const readme = `<details>
376
+ <summary>config</summary>
377
+ unknownOne: 'value-one'
378
+ unknownTwo: 42
379
+ </details>`;
380
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
381
+
382
+ parseProjectReadmeConfig(
383
+ readme,
384
+ 'https://github.com/orgs/example/projects/1',
385
+ );
386
+
387
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
388
+ 'Unknown key "unknownOne" in project README config section (project URL: https://github.com/orgs/example/projects/1)',
389
+ );
390
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
391
+ 'Unknown key "unknownTwo" in project README config section (project URL: https://github.com/orgs/example/projects/1)',
392
+ );
393
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
394
+
395
+ consoleWarnSpy.mockRestore();
396
+ });
397
+
398
+ it('should warn only for unknown keys in a mixed config', () => {
399
+ const readme = `<details>
400
+ <summary>config</summary>
401
+ defaultAgentName: 'agent'
402
+ unexpectedKey: 'oops'
403
+ </details>`;
404
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
405
+
406
+ const result = parseProjectReadmeConfig(
407
+ readme,
408
+ 'https://github.com/orgs/example/projects/2',
409
+ );
410
+
411
+ expect(result.defaultAgentName).toBe('agent');
412
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
413
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
414
+ 'Unknown key "unexpectedKey" in project README config section (project URL: https://github.com/orgs/example/projects/2)',
415
+ );
416
+
417
+ consoleWarnSpy.mockRestore();
418
+ });
419
+
420
+ it('should warn for a typo of a known key (maximumPreparingIssueCount missing trailing s)', () => {
421
+ const readme = `<details>
422
+ <summary>config</summary>
423
+ maximumPreparingIssueCount: 9
424
+ </details>`;
425
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
426
+
427
+ const result = parseProjectReadmeConfig(
428
+ readme,
429
+ 'https://github.com/orgs/example/projects/3',
430
+ );
431
+
432
+ expect(result.maximumPreparingIssuesCount).toBeUndefined();
433
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
434
+ 'Unknown key "maximumPreparingIssueCount" in project README config section (project URL: https://github.com/orgs/example/projects/3)',
435
+ );
436
+
437
+ consoleWarnSpy.mockRestore();
438
+ });
439
+
440
+ it('should warn without project URL suffix when projectUrl is not provided', () => {
441
+ const readme = `<details>
442
+ <summary>config</summary>
443
+ mysteryKey: 'value'
444
+ </details>`;
445
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
446
+
447
+ parseProjectReadmeConfig(readme);
448
+
449
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
450
+ 'Unknown key "mysteryKey" in project README config section',
451
+ );
452
+
453
+ consoleWarnSpy.mockRestore();
454
+ });
353
455
  });
354
456
 
355
457
  describe('mergeConfigs', () => {
@@ -161,7 +161,7 @@ program
161
161
  if (tempProjectUrl) {
162
162
  const readme = await fetchProjectReadme(tempProjectUrl, token);
163
163
  if (readme) {
164
- readmeOverrides = parseProjectReadmeConfig(readme);
164
+ readmeOverrides = parseProjectReadmeConfig(readme, tempProjectUrl);
165
165
  }
166
166
  }
167
167
 
@@ -348,7 +348,7 @@ program
348
348
  if (tempProjectUrl) {
349
349
  const readme = await fetchProjectReadme(tempProjectUrl, token);
350
350
  if (readme) {
351
- readmeOverrides = parseProjectReadmeConfig(readme);
351
+ readmeOverrides = parseProjectReadmeConfig(readme, tempProjectUrl);
352
352
  }
353
353
  }
354
354
 
@@ -58,6 +58,23 @@ const getStringArrayValue = (
58
58
  export const isRecord = (value: unknown): value is Record<string, unknown> =>
59
59
  typeof value === 'object' && value !== null && !Array.isArray(value);
60
60
 
61
+ const knownProjectReadmeConfigKeys = [
62
+ 'defaultAgentName',
63
+ 'defaultLlmModelName',
64
+ 'defaultLlmAgentName',
65
+ 'maximumPreparingIssuesCount',
66
+ 'allowIssueCacheMinutes',
67
+ 'utilizationPercentageThreshold',
68
+ 'allowedIssueAuthors',
69
+ 'thresholdForAutoReject',
70
+ 'workflowBlockerResolvedWebhookUrl',
71
+ 'preparationProcessCheckCommand',
72
+ 'codexHomeCandidates',
73
+ 'claudeCodeOauthTokenListJsonPath',
74
+ 'awLogDirectoryPath',
75
+ 'awLogStaleThresholdMinutes',
76
+ ] as const;
77
+
61
78
  export const loadConfigFile = (configFilePath: string): ConfigFile => {
62
79
  try {
63
80
  const content = fs.readFileSync(configFilePath, 'utf-8');
@@ -111,7 +128,10 @@ export const loadConfigFile = (configFilePath: string): ConfigFile => {
111
128
  }
112
129
  };
113
130
 
114
- export const parseProjectReadmeConfig = (readme: string): ConfigFile => {
131
+ export const parseProjectReadmeConfig = (
132
+ readme: string,
133
+ projectUrl?: string,
134
+ ): ConfigFile => {
115
135
  const detailsRegex =
116
136
  /<details>\s*<summary>config<\/summary>([\s\S]*?)<\/details>/i;
117
137
  const match = detailsRegex.exec(readme);
@@ -127,6 +147,15 @@ export const parseProjectReadmeConfig = (readme: string): ConfigFile => {
127
147
  if (!isRecord(parsed)) {
128
148
  return {};
129
149
  }
150
+ const knownKeySet = new Set<string>(knownProjectReadmeConfigKeys);
151
+ const projectUrlSuffix = projectUrl ? ` (project URL: ${projectUrl})` : '';
152
+ for (const key of Object.keys(parsed)) {
153
+ if (!knownKeySet.has(key)) {
154
+ console.warn(
155
+ `Unknown key "${key}" in project README config section${projectUrlSuffix}`,
156
+ );
157
+ }
158
+ }
130
159
  return {
131
160
  defaultAgentName: getStringValue(parsed, 'defaultAgentName'),
132
161
  defaultLlmModelName: getStringValue(parsed, 'defaultLlmModelName'),
@@ -457,4 +457,98 @@ claudeCodeOauthTokenListJsonPath: /readme/tokens.json
457
457
  );
458
458
  });
459
459
  });
460
+
461
+ describe('effective config logging', () => {
462
+ let consoleLogSpy: jest.SpyInstance;
463
+
464
+ beforeEach(() => {
465
+ consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
466
+ });
467
+
468
+ afterEach(() => {
469
+ consoleLogSpy.mockRestore();
470
+ });
471
+
472
+ it('should log the effective values with configFile source when only the YAML config sets them', async () => {
473
+ const configWithPreparation = {
474
+ ...validConfig,
475
+ startPreparation: {
476
+ defaultAgentName: 'yaml-agent',
477
+ defaultLlmModelName: 'yaml-model',
478
+ configFilePath: './config.yml',
479
+ maximumPreparingIssuesCount: 10,
480
+ },
481
+ };
482
+ mockFetchReturningReadme(null);
483
+ jest
484
+ .mocked(fs.readFileSync)
485
+ .mockReturnValue(YAML.stringify(configWithPreparation));
486
+
487
+ const handler = new HandleScheduledEventUseCaseHandler();
488
+ await handler.handle('config.yml', false);
489
+
490
+ expect(consoleLogSpy).toHaveBeenCalledWith(
491
+ 'Effective maximumPreparingIssuesCount: 10 (source: configFile)',
492
+ );
493
+ expect(consoleLogSpy).toHaveBeenCalledWith(
494
+ 'Effective defaultLlmModelName: yaml-model (source: configFile)',
495
+ );
496
+ expect(consoleLogSpy).toHaveBeenCalledWith(
497
+ 'Effective defaultAgentName: yaml-agent (source: configFile)',
498
+ );
499
+ });
500
+
501
+ it('should log the effective values with readmeOverride source when the README config overrides them', async () => {
502
+ const readmeContent = `<details>
503
+ <summary>config</summary>
504
+ maximumPreparingIssuesCount: 3
505
+ defaultLlmModelName: readme-model
506
+ defaultAgentName: readme-agent
507
+ </details>`;
508
+ mockFetchReturningReadme(readmeContent);
509
+ const configWithPreparation = {
510
+ ...validConfig,
511
+ startPreparation: {
512
+ defaultAgentName: 'yaml-agent',
513
+ defaultLlmModelName: 'yaml-model',
514
+ configFilePath: './config.yml',
515
+ maximumPreparingIssuesCount: 10,
516
+ },
517
+ };
518
+ jest
519
+ .mocked(fs.readFileSync)
520
+ .mockReturnValue(YAML.stringify(configWithPreparation));
521
+
522
+ const handler = new HandleScheduledEventUseCaseHandler();
523
+ await handler.handle('config.yml', false);
524
+
525
+ expect(consoleLogSpy).toHaveBeenCalledWith(
526
+ 'Effective maximumPreparingIssuesCount: 3 (source: readmeOverride)',
527
+ );
528
+ expect(consoleLogSpy).toHaveBeenCalledWith(
529
+ 'Effective defaultLlmModelName: readme-model (source: readmeOverride)',
530
+ );
531
+ expect(consoleLogSpy).toHaveBeenCalledWith(
532
+ 'Effective defaultAgentName: readme-agent (source: readmeOverride)',
533
+ );
534
+ });
535
+
536
+ it('should log null with unset (default) source when neither README nor config provides the value', async () => {
537
+ mockFetchReturningReadme(null);
538
+ jest.mocked(fs.readFileSync).mockReturnValue(YAML.stringify(validConfig));
539
+
540
+ const handler = new HandleScheduledEventUseCaseHandler();
541
+ await handler.handle('config.yml', false);
542
+
543
+ expect(consoleLogSpy).toHaveBeenCalledWith(
544
+ 'Effective maximumPreparingIssuesCount: null (source: unset (default))',
545
+ );
546
+ expect(consoleLogSpy).toHaveBeenCalledWith(
547
+ 'Effective defaultLlmModelName: null (source: unset (default))',
548
+ );
549
+ expect(consoleLogSpy).toHaveBeenCalledWith(
550
+ 'Effective defaultAgentName: null (source: unset (default))',
551
+ );
552
+ });
553
+ });
460
554
  });
@@ -94,7 +94,9 @@ export class HandleScheduledEventUseCaseHandler {
94
94
 
95
95
  const managerToken = input.credentials.manager.github.token;
96
96
  const readme = await fetchProjectReadme(input.projectUrl, managerToken);
97
- const readmeConfig = readme ? parseProjectReadmeConfig(readme) : {};
97
+ const readmeConfig = readme
98
+ ? parseProjectReadmeConfig(readme, input.projectUrl)
99
+ : {};
98
100
 
99
101
  const mergedInput = {
100
102
  ...input,
@@ -139,6 +141,50 @@ export class HandleScheduledEventUseCaseHandler {
139
141
  : input.startPreparation,
140
142
  };
141
143
 
144
+ type EffectiveConfigValue = string | number | null | undefined;
145
+
146
+ const resolveConfigSource = (
147
+ readmeValue: EffectiveConfigValue,
148
+ configFileValue: EffectiveConfigValue,
149
+ ): 'readmeOverride' | 'configFile' | 'unset (default)' => {
150
+ if (readmeValue !== undefined && readmeValue !== null) {
151
+ return 'readmeOverride';
152
+ }
153
+ if (configFileValue !== undefined && configFileValue !== null) {
154
+ return 'configFile';
155
+ }
156
+ return 'unset (default)';
157
+ };
158
+
159
+ const formatEffectiveConfig = (
160
+ value: EffectiveConfigValue,
161
+ readmeValue: EffectiveConfigValue,
162
+ configFileValue: EffectiveConfigValue,
163
+ ): string =>
164
+ `${value ?? 'null'} (source: ${resolveConfigSource(readmeValue, configFileValue)})`;
165
+
166
+ console.log(
167
+ `Effective maximumPreparingIssuesCount: ${formatEffectiveConfig(
168
+ mergedInput.startPreparation?.maximumPreparingIssuesCount,
169
+ readmeConfig.maximumPreparingIssuesCount,
170
+ input.startPreparation?.maximumPreparingIssuesCount,
171
+ )}`,
172
+ );
173
+ console.log(
174
+ `Effective defaultLlmModelName: ${formatEffectiveConfig(
175
+ mergedInput.startPreparation?.defaultLlmModelName,
176
+ readmeConfig.defaultLlmModelName,
177
+ input.startPreparation?.defaultLlmModelName,
178
+ )}`,
179
+ );
180
+ console.log(
181
+ `Effective defaultAgentName: ${formatEffectiveConfig(
182
+ mergedInput.startPreparation?.defaultAgentName,
183
+ readmeConfig.defaultAgentName,
184
+ input.startPreparation?.defaultAgentName,
185
+ )}`,
186
+ );
187
+
142
188
  const systemDateRepository = new SystemDateRepository();
143
189
  const localStorageRepository = new LocalStorageRepository();
144
190
  const googleSpreadsheetRepository = new GoogleSpreadsheetRepository(
@@ -218,6 +218,49 @@ describe('RateLimitCache', () => {
218
218
  const snapshot = readRateLimit(token);
219
219
  expect(snapshot?.modelWeeklyLimits).toEqual({});
220
220
  });
221
+
222
+ it('should expose ts as lastUpdatedEpoch in the snapshot', () => {
223
+ const token = 'ts-snapshot-token';
224
+ const before = Date.now() / 1000;
225
+ writeRateLimit(token, {
226
+ 'anthropic-ratelimit-unified-status': 'allowed',
227
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
228
+ 'anthropic-ratelimit-unified-5h-reset': '1700000000',
229
+ 'anthropic-ratelimit-unified-5h-utilization': '10',
230
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
231
+ 'anthropic-ratelimit-unified-7d-reset': '1700100000',
232
+ 'anthropic-ratelimit-unified-7d-utilization': '5',
233
+ });
234
+ const after = Date.now() / 1000;
235
+ const snapshot = readRateLimit(token);
236
+ expect(snapshot).not.toBeNull();
237
+ if (snapshot === null) return;
238
+ expect(snapshot.lastUpdatedEpoch).toBeGreaterThanOrEqual(before);
239
+ expect(snapshot.lastUpdatedEpoch).toBeLessThanOrEqual(after);
240
+ });
241
+
242
+ it('should default lastUpdatedEpoch to 0 when the stored payload has no ts field', () => {
243
+ const token = 'no-ts-token';
244
+ fs.mkdirSync(cacheDir(), { recursive: true });
245
+ fs.writeFileSync(
246
+ cachePathForToken(token),
247
+ JSON.stringify({
248
+ headers: {
249
+ 'anthropic-ratelimit-unified-status': 'allowed',
250
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
251
+ 'anthropic-ratelimit-unified-5h-reset': '1700000000',
252
+ 'anthropic-ratelimit-unified-5h-utilization': '10',
253
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
254
+ 'anthropic-ratelimit-unified-7d-reset': '1700100000',
255
+ 'anthropic-ratelimit-unified-7d-utilization': '5',
256
+ },
257
+ }),
258
+ );
259
+ const snapshot = readRateLimit(token);
260
+ expect(snapshot).not.toBeNull();
261
+ if (snapshot === null) return;
262
+ expect(snapshot.lastUpdatedEpoch).toBe(0);
263
+ });
221
264
  });
222
265
 
223
266
  describe('writeRateLimit stores all anthropic-ratelimit-* headers', () => {
@@ -19,6 +19,7 @@ export interface RateLimitSnapshot {
19
19
  fiveHourRejected: boolean;
20
20
  sevenDayRejected: boolean;
21
21
  modelWeeklyLimits: Record<string, ModelWeeklyLimit>;
22
+ lastUpdatedEpoch: number;
22
23
  }
23
24
 
24
25
  export const PROXY_PORT = 8787;
@@ -183,6 +184,8 @@ export const readRateLimit = (token: string): RateLimitSnapshot | null => {
183
184
  const unifiedRejected = status === 'rejected';
184
185
  const fiveHourRejected = fiveHourStatus === 'rejected';
185
186
  const sevenDayRejected = sevenDayStatus === 'rejected';
187
+ const storedTs = parsed.ts;
188
+ const lastUpdatedEpoch = typeof storedTs === 'number' ? storedTs : 0;
186
189
  return {
187
190
  fiveHourUtilization: num('anthropic-ratelimit-unified-5h-utilization'),
188
191
  fiveHourReset: num('anthropic-ratelimit-unified-5h-reset'),
@@ -197,6 +200,7 @@ export const readRateLimit = (token: string): RateLimitSnapshot | null => {
197
200
  fiveHourRejected,
198
201
  sevenDayRejected,
199
202
  modelWeeklyLimits: readModelWeeklyLimits(parsed),
203
+ lastUpdatedEpoch,
200
204
  };
201
205
  } catch {
202
206
  return null;
@@ -37,8 +37,9 @@ describe('ProxyRateLimitCacheRepository', () => {
37
37
  });
38
38
 
39
39
  const futureReset = Math.floor(Date.now() / 1000) + 3600;
40
+ const recentEpoch = Math.floor(Date.now() / 1000) - 60;
40
41
 
41
- it('should return fiveHourReset as unifiedReset for a token with a cached snapshot', () => {
42
+ it('should return fiveHourReset as unifiedReset and ts as lastProbeEpoch for a token with a cached snapshot', () => {
42
43
  mockLoadTokens.mockReturnValue(['token-a']);
43
44
  mockReadRateLimit.mockReturnValue({
44
45
  fiveHourUtilization: 42,
@@ -51,22 +52,31 @@ describe('ProxyRateLimitCacheRepository', () => {
51
52
  fiveHourRejected: false,
52
53
  sevenDayRejected: false,
53
54
  modelWeeklyLimits: {},
55
+ lastUpdatedEpoch: recentEpoch,
54
56
  });
55
57
  const repository = new ProxyRateLimitCacheRepository('/tokens.json');
56
58
 
57
59
  const result = repository.getTokenRateLimitCaches();
58
60
 
59
- expect(result).toEqual([{ token: 'token-a', unifiedReset: futureReset }]);
61
+ expect(result).toEqual([
62
+ {
63
+ token: 'token-a',
64
+ unifiedReset: futureReset,
65
+ lastProbeEpoch: recentEpoch,
66
+ },
67
+ ]);
60
68
  });
61
69
 
62
- it('should return unifiedReset of 0 when no snapshot exists for a token', () => {
70
+ it('should return unifiedReset of 0 and lastProbeEpoch of 0 when no snapshot exists for a token', () => {
63
71
  mockLoadTokens.mockReturnValue(['token-a']);
64
72
  mockReadRateLimit.mockReturnValue(null);
65
73
  const repository = new ProxyRateLimitCacheRepository('/tokens.json');
66
74
 
67
75
  const result = repository.getTokenRateLimitCaches();
68
76
 
69
- expect(result).toEqual([{ token: 'token-a', unifiedReset: 0 }]);
77
+ expect(result).toEqual([
78
+ { token: 'token-a', unifiedReset: 0, lastProbeEpoch: 0 },
79
+ ]);
70
80
  });
71
81
 
72
82
  it('should return entries for all tokens in the list', () => {
@@ -84,6 +94,7 @@ describe('ProxyRateLimitCacheRepository', () => {
84
94
  fiveHourRejected: false,
85
95
  sevenDayRejected: false,
86
96
  modelWeeklyLimits: {},
97
+ lastUpdatedEpoch: recentEpoch,
87
98
  };
88
99
  }
89
100
  return null;
@@ -93,8 +104,12 @@ describe('ProxyRateLimitCacheRepository', () => {
93
104
  const result = repository.getTokenRateLimitCaches();
94
105
 
95
106
  expect(result).toEqual([
96
- { token: 'token-a', unifiedReset: futureReset },
97
- { token: 'token-b', unifiedReset: 0 },
107
+ {
108
+ token: 'token-a',
109
+ unifiedReset: futureReset,
110
+ lastProbeEpoch: recentEpoch,
111
+ },
112
+ { token: 'token-b', unifiedReset: 0, lastProbeEpoch: 0 },
98
113
  ]);
99
114
  });
100
115
  });
@@ -29,7 +29,8 @@ export class ProxyRateLimitCacheRepository implements RateLimitCacheRepository {
29
29
  return tokens.map((token) => {
30
30
  const snapshot = readRateLimit(token);
31
31
  const unifiedReset = snapshot !== null ? snapshot.fiveHourReset : 0;
32
- return { token, unifiedReset };
32
+ const lastProbeEpoch = snapshot !== null ? snapshot.lastUpdatedEpoch : 0;
33
+ return { token, unifiedReset, lastProbeEpoch };
33
34
  });
34
35
  };
35
36
 
@@ -12,10 +12,14 @@ describe('UpdateRateLimitCacheUseCase', () => {
12
12
  });
13
13
 
14
14
  it('should probe a token whose unifiedReset is in the past', async () => {
15
- const pastReset = 1000000000;
16
15
  const nowEpochSeconds = 1000000100;
16
+ const pastReset = 1000000000;
17
17
  mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
18
- { token: 'token-expired', unifiedReset: pastReset },
18
+ {
19
+ token: 'token-expired',
20
+ unifiedReset: pastReset,
21
+ lastProbeEpoch: nowEpochSeconds,
22
+ },
19
23
  ]);
20
24
  mockRateLimitCacheRepository.probeToken.mockResolvedValue(undefined);
21
25
 
@@ -26,11 +30,16 @@ describe('UpdateRateLimitCacheUseCase', () => {
26
30
  );
27
31
  });
28
32
 
29
- it('should not probe a token whose unifiedReset is in the future', async () => {
30
- const futureReset = 9999999999;
33
+ it('should not probe a token whose unifiedReset is in the future and was probed within the last hour', async () => {
31
34
  const nowEpochSeconds = 1000000000;
35
+ const futureReset = nowEpochSeconds + 1800;
36
+ const tenMinutesAgo = nowEpochSeconds - 600;
32
37
  mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
33
- { token: 'token-active', unifiedReset: futureReset },
38
+ {
39
+ token: 'token-active',
40
+ unifiedReset: futureReset,
41
+ lastProbeEpoch: tenMinutesAgo,
42
+ },
34
43
  ]);
35
44
 
36
45
  await useCase.run({ nowEpochSeconds });
@@ -39,12 +48,20 @@ describe('UpdateRateLimitCacheUseCase', () => {
39
48
  });
40
49
 
41
50
  it('should probe only tokens with expired reset when mixed tokens exist', async () => {
42
- const pastReset = 1000000000;
43
- const futureReset = 9999999999;
44
51
  const nowEpochSeconds = 1000000100;
52
+ const pastReset = 1000000000;
53
+ const futureReset = nowEpochSeconds + 1800;
45
54
  mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
46
- { token: 'token-expired', unifiedReset: pastReset },
47
- { token: 'token-active', unifiedReset: futureReset },
55
+ {
56
+ token: 'token-expired',
57
+ unifiedReset: pastReset,
58
+ lastProbeEpoch: nowEpochSeconds,
59
+ },
60
+ {
61
+ token: 'token-active',
62
+ unifiedReset: futureReset,
63
+ lastProbeEpoch: nowEpochSeconds,
64
+ },
48
65
  ]);
49
66
  mockRateLimitCacheRepository.probeToken.mockResolvedValue(undefined);
50
67
 
@@ -57,10 +74,14 @@ describe('UpdateRateLimitCacheUseCase', () => {
57
74
  });
58
75
 
59
76
  it('should update the cache after probing by relying on the repository side effect', async () => {
60
- const pastReset = 1000000000;
61
77
  const nowEpochSeconds = 1000000100;
78
+ const pastReset = 1000000000;
62
79
  mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
63
- { token: 'token-expired', unifiedReset: pastReset },
80
+ {
81
+ token: 'token-expired',
82
+ unifiedReset: pastReset,
83
+ lastProbeEpoch: nowEpochSeconds,
84
+ },
64
85
  ]);
65
86
  mockRateLimitCacheRepository.probeToken.mockResolvedValue(undefined);
66
87
 
@@ -78,4 +99,80 @@ describe('UpdateRateLimitCacheUseCase', () => {
78
99
 
79
100
  expect(mockRateLimitCacheRepository.probeToken).not.toHaveBeenCalled();
80
101
  });
102
+
103
+ it('should probe a token whose last probe was 61 minutes ago even when unifiedReset is in the future', async () => {
104
+ const nowEpochSeconds = 1000000000;
105
+ const futureReset = nowEpochSeconds + 7200;
106
+ const sixtyOneMinutesAgo = nowEpochSeconds - 61 * 60;
107
+ mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
108
+ {
109
+ token: 'token-stale-probe',
110
+ unifiedReset: futureReset,
111
+ lastProbeEpoch: sixtyOneMinutesAgo,
112
+ },
113
+ ]);
114
+ mockRateLimitCacheRepository.probeToken.mockResolvedValue(undefined);
115
+
116
+ await useCase.run({ nowEpochSeconds });
117
+
118
+ expect(mockRateLimitCacheRepository.probeToken).toHaveBeenCalledWith(
119
+ 'token-stale-probe',
120
+ );
121
+ });
122
+
123
+ it('should not probe a token whose last probe was 10 minutes ago', async () => {
124
+ const nowEpochSeconds = 1000000000;
125
+ const futureReset = nowEpochSeconds + 7200;
126
+ const tenMinutesAgo = nowEpochSeconds - 10 * 60;
127
+ mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
128
+ {
129
+ token: 'token-fresh-probe',
130
+ unifiedReset: futureReset,
131
+ lastProbeEpoch: tenMinutesAgo,
132
+ },
133
+ ]);
134
+
135
+ await useCase.run({ nowEpochSeconds });
136
+
137
+ expect(mockRateLimitCacheRepository.probeToken).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it('should probe a token with unifiedReset in the past regardless of last probe time', async () => {
141
+ const nowEpochSeconds = 1000000000;
142
+ const pastReset = nowEpochSeconds - 60;
143
+ const oneMinuteAgo = nowEpochSeconds - 60;
144
+ mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
145
+ {
146
+ token: 'token-expired-recent-probe',
147
+ unifiedReset: pastReset,
148
+ lastProbeEpoch: oneMinuteAgo,
149
+ },
150
+ ]);
151
+ mockRateLimitCacheRepository.probeToken.mockResolvedValue(undefined);
152
+
153
+ await useCase.run({ nowEpochSeconds });
154
+
155
+ expect(mockRateLimitCacheRepository.probeToken).toHaveBeenCalledWith(
156
+ 'token-expired-recent-probe',
157
+ );
158
+ });
159
+
160
+ it('should probe a token that has never been probed even when unifiedReset is in the future', async () => {
161
+ const nowEpochSeconds = 1000000000;
162
+ const futureReset = nowEpochSeconds + 7200;
163
+ mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
164
+ {
165
+ token: 'token-never-probed',
166
+ unifiedReset: futureReset,
167
+ lastProbeEpoch: 0,
168
+ },
169
+ ]);
170
+ mockRateLimitCacheRepository.probeToken.mockResolvedValue(undefined);
171
+
172
+ await useCase.run({ nowEpochSeconds });
173
+
174
+ expect(mockRateLimitCacheRepository.probeToken).toHaveBeenCalledWith(
175
+ 'token-never-probed',
176
+ );
177
+ });
81
178
  });
@@ -1,5 +1,7 @@
1
1
  import { RateLimitCacheRepository } from './adapter-interfaces/RateLimitCacheRepository';
2
2
 
3
+ const HOURLY_PROBE_INTERVAL_SECONDS = 3600;
4
+
3
5
  export class UpdateRateLimitCacheUseCase {
4
6
  constructor(
5
7
  private readonly rateLimitCacheRepository: RateLimitCacheRepository,
@@ -8,7 +10,11 @@ export class UpdateRateLimitCacheUseCase {
8
10
  run = async (params: { nowEpochSeconds: number }): Promise<void> => {
9
11
  const caches = this.rateLimitCacheRepository.getTokenRateLimitCaches();
10
12
  for (const cache of caches) {
11
- if (cache.unifiedReset < params.nowEpochSeconds) {
13
+ const unifiedResetExpired = cache.unifiedReset < params.nowEpochSeconds;
14
+ const hourlyProbeDue =
15
+ params.nowEpochSeconds - cache.lastProbeEpoch >=
16
+ HOURLY_PROBE_INTERVAL_SECONDS;
17
+ if (unifiedResetExpired || hourlyProbeDue) {
12
18
  await this.rateLimitCacheRepository.probeToken(cache.token);
13
19
  }
14
20
  }
@@ -1,6 +1,7 @@
1
1
  export interface TokenRateLimitCache {
2
2
  token: string;
3
3
  unifiedReset: number;
4
+ lastProbeEpoch: number;
4
5
  }
5
6
 
6
7
  export interface RateLimitCacheRepository {