github-issue-tower-defence-management 1.58.3 → 1.60.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 (67) hide show
  1. package/.gitattributes +2 -0
  2. package/.github/workflows/create-pr.yml +1 -1
  3. package/.github/workflows/test.yml +4 -0
  4. package/.github/workflows/umino-project.yml +3 -3
  5. package/.prettierignore +1 -0
  6. package/CHANGELOG.md +19 -0
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +7 -1
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/proxy/ClaudeMessageResponseParser.js +123 -0
  10. package/bin/adapter/proxy/ClaudeMessageResponseParser.js.map +1 -0
  11. package/bin/adapter/proxy/RateLimitCache.js +10 -9
  12. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  13. package/bin/adapter/proxy/proxyEntry.js +16 -3
  14. package/bin/adapter/proxy/proxyEntry.js.map +1 -1
  15. package/bin/adapter/repositories/ProxyRateLimitCacheRepository.js +92 -0
  16. package/bin/adapter/repositories/ProxyRateLimitCacheRepository.js.map +1 -0
  17. package/bin/adapter/repositories/SqliteClaudeMessageResponseRepository.js +186 -0
  18. package/bin/adapter/repositories/SqliteClaudeMessageResponseRepository.js.map +1 -0
  19. package/bin/domain/entities/ClaudeMessageResponse.js +3 -0
  20. package/bin/domain/entities/ClaudeMessageResponse.js.map +1 -0
  21. package/bin/domain/usecases/HandleScheduledEventUseCase.js +7 -1
  22. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  23. package/bin/domain/usecases/UpdateRateLimitCacheUseCase.js +18 -0
  24. package/bin/domain/usecases/UpdateRateLimitCacheUseCase.js.map +1 -0
  25. package/bin/domain/usecases/adapter-interfaces/ClaudeMessageResponseRepository.js +3 -0
  26. package/bin/domain/usecases/adapter-interfaces/ClaudeMessageResponseRepository.js.map +1 -0
  27. package/bin/domain/usecases/adapter-interfaces/RateLimitCacheRepository.js +3 -0
  28. package/bin/domain/usecases/adapter-interfaces/RateLimitCacheRepository.js.map +1 -0
  29. package/package.json +3 -1
  30. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +6 -0
  31. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +9 -0
  32. package/src/adapter/proxy/ClaudeMessageResponseParser.test.ts +211 -0
  33. package/src/adapter/proxy/ClaudeMessageResponseParser.ts +180 -0
  34. package/src/adapter/proxy/RateLimitCache.test.ts +60 -0
  35. package/src/adapter/proxy/RateLimitCache.ts +10 -23
  36. package/src/adapter/proxy/proxyEntry.ts +28 -3
  37. package/src/adapter/repositories/ProxyRateLimitCacheRepository.test.ts +101 -0
  38. package/src/adapter/repositories/ProxyRateLimitCacheRepository.ts +66 -0
  39. package/src/adapter/repositories/SqliteClaudeMessageResponseRepository.test.ts +313 -0
  40. package/src/adapter/repositories/SqliteClaudeMessageResponseRepository.ts +164 -0
  41. package/src/domain/entities/ClaudeMessageResponse.ts +31 -0
  42. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +63 -0
  43. package/src/domain/usecases/HandleScheduledEventUseCase.ts +7 -0
  44. package/src/domain/usecases/UpdateRateLimitCacheUseCase.test.ts +81 -0
  45. package/src/domain/usecases/UpdateRateLimitCacheUseCase.ts +16 -0
  46. package/src/domain/usecases/adapter-interfaces/ClaudeMessageResponseRepository.ts +5 -0
  47. package/src/domain/usecases/adapter-interfaces/RateLimitCacheRepository.ts +9 -0
  48. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  49. package/types/adapter/proxy/ClaudeMessageResponseParser.d.ts +4 -0
  50. package/types/adapter/proxy/ClaudeMessageResponseParser.d.ts.map +1 -0
  51. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  52. package/types/adapter/proxy/proxyEntry.d.ts +2 -1
  53. package/types/adapter/proxy/proxyEntry.d.ts.map +1 -1
  54. package/types/adapter/repositories/ProxyRateLimitCacheRepository.d.ts +10 -0
  55. package/types/adapter/repositories/ProxyRateLimitCacheRepository.d.ts.map +1 -0
  56. package/types/adapter/repositories/SqliteClaudeMessageResponseRepository.d.ts +10 -0
  57. package/types/adapter/repositories/SqliteClaudeMessageResponseRepository.d.ts.map +1 -0
  58. package/types/domain/entities/ClaudeMessageResponse.d.ts +32 -0
  59. package/types/domain/entities/ClaudeMessageResponse.d.ts.map +1 -0
  60. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -1
  61. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  62. package/types/domain/usecases/UpdateRateLimitCacheUseCase.d.ts +9 -0
  63. package/types/domain/usecases/UpdateRateLimitCacheUseCase.d.ts.map +1 -0
  64. package/types/domain/usecases/adapter-interfaces/ClaudeMessageResponseRepository.d.ts +5 -0
  65. package/types/domain/usecases/adapter-interfaces/ClaudeMessageResponseRepository.d.ts.map +1 -0
  66. package/types/domain/usecases/adapter-interfaces/RateLimitCacheRepository.d.ts +9 -0
  67. package/types/domain/usecases/adapter-interfaces/RateLimitCacheRepository.d.ts.map +1 -0
@@ -0,0 +1,313 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import Database from 'better-sqlite3';
5
+ import {
6
+ SqliteClaudeMessageResponseRepository,
7
+ generateUlid,
8
+ } from './SqliteClaudeMessageResponseRepository';
9
+ import { ClaudeMessageResponse } from '../../domain/entities/ClaudeMessageResponse';
10
+
11
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
12
+ value !== null && typeof value === 'object' && !Array.isArray(value);
13
+
14
+ const requireRecord = (value: unknown): Record<string, unknown> => {
15
+ if (!isRecord(value))
16
+ throw new Error(`Expected record, got: ${String(value)}`);
17
+ return value;
18
+ };
19
+
20
+ const requireRecordArray = (value: unknown[]): Array<Record<string, unknown>> =>
21
+ value.map(requireRecord);
22
+
23
+ const buildTestResponse = (
24
+ overrides: Partial<ClaudeMessageResponse> = {},
25
+ ): ClaudeMessageResponse => ({
26
+ id: generateUlid(),
27
+ observedAt: new Date('2024-01-15T10:30:00.000Z'),
28
+ tokenName: 'hashed-token-abc',
29
+ externalClaudeMessageId: 'msg_01XFDUDYJgAACTvykiHMn8xy',
30
+ externalClaudeRequestId: 'req_01234',
31
+ httpStatus: 200,
32
+ model: 'claude-sonnet-4-5',
33
+ role: 'assistant',
34
+ stopReason: 'end_turn',
35
+ stopSequence: null,
36
+ inputTokens: 100,
37
+ outputTokens: 50,
38
+ cacheCreationInputTokens: null,
39
+ cacheReadInputTokens: null,
40
+ ephemeral5mInputTokens: null,
41
+ ephemeral1hInputTokens: null,
42
+ serviceTier: 'standard',
43
+ inferenceGeo: null,
44
+ errorType: null,
45
+ errorMessage: null,
46
+ anthropicRatelimitUnifiedStatus: 'active',
47
+ anthropicRatelimitUnified5hStatus: 'active',
48
+ anthropicRatelimitUnified5hUtilization: 42.5,
49
+ anthropicRatelimitUnified5hReset: 1705316200,
50
+ anthropicRatelimitUnified7dStatus: 'active',
51
+ anthropicRatelimitUnified7dUtilization: 10.0,
52
+ anthropicRatelimitUnified7dReset: 1705920000,
53
+ retryAfter: null,
54
+ anthropicOrganizationId: 'org-abc123',
55
+ ...overrides,
56
+ });
57
+
58
+ describe('generateUlid', () => {
59
+ it('generates a 26-character uppercase alphanumeric string', () => {
60
+ const ulid = generateUlid();
61
+ expect(ulid).toHaveLength(26);
62
+ expect(/^[0-9A-HJKMNP-TV-Z]{26}$/.test(ulid)).toBe(true);
63
+ });
64
+
65
+ it('generates unique values across multiple calls', () => {
66
+ const ids = new Set(Array.from({ length: 100 }, () => generateUlid()));
67
+ expect(ids.size).toBe(100);
68
+ });
69
+
70
+ it('generates a value whose first 10 characters encode a timestamp no later than the current time', () => {
71
+ const before = Date.now();
72
+ const ulid = generateUlid();
73
+ const after = Date.now();
74
+ const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
75
+ const timeChars = ulid.slice(0, 10);
76
+ let decoded = 0;
77
+ for (const char of timeChars) {
78
+ decoded = decoded * 32 + ENCODING.indexOf(char);
79
+ }
80
+ expect(decoded).toBeGreaterThanOrEqual(before);
81
+ expect(decoded).toBeLessThanOrEqual(after);
82
+ });
83
+ });
84
+
85
+ describe('SqliteClaudeMessageResponseRepository', () => {
86
+ let tmpDir: string;
87
+ let dbPath: string;
88
+
89
+ beforeEach(() => {
90
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-msg-repo-test-'));
91
+ dbPath = path.join(tmpDir, 'claude_message_response.db');
92
+ });
93
+
94
+ afterEach(() => {
95
+ fs.rmSync(tmpDir, { recursive: true, force: true });
96
+ });
97
+
98
+ describe('constructor', () => {
99
+ it('creates the database file when it does not exist', () => {
100
+ new SqliteClaudeMessageResponseRepository(dbPath);
101
+ expect(fs.existsSync(dbPath)).toBe(true);
102
+ });
103
+
104
+ it('creates the parent directory when it does not exist', () => {
105
+ const nestedDbPath = path.join(tmpDir, 'nested', 'dir', 'test.db');
106
+ new SqliteClaudeMessageResponseRepository(nestedDbPath);
107
+ expect(fs.existsSync(nestedDbPath)).toBe(true);
108
+ });
109
+
110
+ it('creates the claude_message_response table on first open', () => {
111
+ new SqliteClaudeMessageResponseRepository(dbPath);
112
+ const db = new Database(dbPath);
113
+ const row = db
114
+ .prepare(
115
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='claude_message_response'",
116
+ )
117
+ .get();
118
+ db.close();
119
+ expect(row).toBeDefined();
120
+ });
121
+
122
+ it('does not fail when reopened against an existing database', () => {
123
+ new SqliteClaudeMessageResponseRepository(dbPath);
124
+ expect(() => {
125
+ new SqliteClaudeMessageResponseRepository(dbPath);
126
+ }).not.toThrow();
127
+ });
128
+ });
129
+
130
+ describe('append', () => {
131
+ it('inserts a row with all fields set', () => {
132
+ const repo = new SqliteClaudeMessageResponseRepository(dbPath);
133
+ const response = buildTestResponse();
134
+
135
+ repo.append(response);
136
+
137
+ const db = new Database(dbPath);
138
+ const row = requireRecord(
139
+ db
140
+ .prepare('SELECT * FROM claude_message_response WHERE id = ?')
141
+ .get(response.id),
142
+ );
143
+ db.close();
144
+
145
+ expect(row['id']).toBe(response.id);
146
+ expect(row['observed_at']).toBe(response.observedAt.getTime());
147
+ expect(row['token_name']).toBe(response.tokenName);
148
+ expect(row['external_claude_message_id']).toBe(
149
+ response.externalClaudeMessageId,
150
+ );
151
+ expect(row['external_claude_request_id']).toBe(
152
+ response.externalClaudeRequestId,
153
+ );
154
+ expect(row['http_status']).toBe(response.httpStatus);
155
+ expect(row['model']).toBe(response.model);
156
+ expect(row['role']).toBe(response.role);
157
+ expect(row['stop_reason']).toBe(response.stopReason);
158
+ expect(row['stop_sequence']).toBeNull();
159
+ expect(row['input_tokens']).toBe(response.inputTokens);
160
+ expect(row['output_tokens']).toBe(response.outputTokens);
161
+ expect(row['cache_creation_input_tokens']).toBeNull();
162
+ expect(row['cache_read_input_tokens']).toBeNull();
163
+ expect(row['ephemeral_5m_input_tokens']).toBeNull();
164
+ expect(row['ephemeral_1h_input_tokens']).toBeNull();
165
+ expect(row['service_tier']).toBe(response.serviceTier);
166
+ expect(row['inference_geo']).toBeNull();
167
+ expect(row['error_type']).toBeNull();
168
+ expect(row['error_message']).toBeNull();
169
+ expect(row['anthropic_ratelimit_unified_status']).toBe(
170
+ response.anthropicRatelimitUnifiedStatus,
171
+ );
172
+ expect(row['anthropic_ratelimit_unified_5h_status']).toBe(
173
+ response.anthropicRatelimitUnified5hStatus,
174
+ );
175
+ expect(row['anthropic_ratelimit_unified_5h_utilization']).toBe(
176
+ response.anthropicRatelimitUnified5hUtilization,
177
+ );
178
+ expect(row['anthropic_ratelimit_unified_5h_reset']).toBe(
179
+ response.anthropicRatelimitUnified5hReset,
180
+ );
181
+ expect(row['anthropic_ratelimit_unified_7d_status']).toBe(
182
+ response.anthropicRatelimitUnified7dStatus,
183
+ );
184
+ expect(row['anthropic_ratelimit_unified_7d_utilization']).toBe(
185
+ response.anthropicRatelimitUnified7dUtilization,
186
+ );
187
+ expect(row['anthropic_ratelimit_unified_7d_reset']).toBe(
188
+ response.anthropicRatelimitUnified7dReset,
189
+ );
190
+ expect(row['retry_after']).toBeNull();
191
+ expect(row['anthropic_organization_id']).toBe(
192
+ response.anthropicOrganizationId,
193
+ );
194
+ });
195
+
196
+ it('inserts a row where all nullable fields are null', () => {
197
+ const repo = new SqliteClaudeMessageResponseRepository(dbPath);
198
+ const response = buildTestResponse({
199
+ externalClaudeMessageId: null,
200
+ externalClaudeRequestId: null,
201
+ model: null,
202
+ role: null,
203
+ stopReason: null,
204
+ stopSequence: null,
205
+ inputTokens: null,
206
+ outputTokens: null,
207
+ cacheCreationInputTokens: null,
208
+ cacheReadInputTokens: null,
209
+ ephemeral5mInputTokens: null,
210
+ ephemeral1hInputTokens: null,
211
+ serviceTier: null,
212
+ inferenceGeo: null,
213
+ errorType: null,
214
+ errorMessage: null,
215
+ anthropicRatelimitUnifiedStatus: null,
216
+ anthropicRatelimitUnified5hStatus: null,
217
+ anthropicRatelimitUnified5hUtilization: null,
218
+ anthropicRatelimitUnified5hReset: null,
219
+ anthropicRatelimitUnified7dStatus: null,
220
+ anthropicRatelimitUnified7dUtilization: null,
221
+ anthropicRatelimitUnified7dReset: null,
222
+ retryAfter: null,
223
+ anthropicOrganizationId: null,
224
+ });
225
+
226
+ repo.append(response);
227
+
228
+ const db = new Database(dbPath);
229
+ const row = requireRecord(
230
+ db
231
+ .prepare('SELECT * FROM claude_message_response WHERE id = ?')
232
+ .get(response.id),
233
+ );
234
+ db.close();
235
+
236
+ expect(row['id']).toBe(response.id);
237
+ expect(row['http_status']).toBe(response.httpStatus);
238
+ expect(row['external_claude_message_id']).toBeNull();
239
+ expect(row['model']).toBeNull();
240
+ });
241
+
242
+ it('inserts an error response with error fields set', () => {
243
+ const repo = new SqliteClaudeMessageResponseRepository(dbPath);
244
+ const response = buildTestResponse({
245
+ httpStatus: 429,
246
+ model: null,
247
+ role: null,
248
+ stopReason: null,
249
+ inputTokens: null,
250
+ outputTokens: null,
251
+ errorType: 'rate_limit_error',
252
+ errorMessage: 'Too many requests',
253
+ retryAfter: 30,
254
+ });
255
+
256
+ repo.append(response);
257
+
258
+ const db = new Database(dbPath);
259
+ const row = requireRecord(
260
+ db
261
+ .prepare('SELECT * FROM claude_message_response WHERE id = ?')
262
+ .get(response.id),
263
+ );
264
+ db.close();
265
+
266
+ expect(row['http_status']).toBe(429);
267
+ expect(row['error_type']).toBe('rate_limit_error');
268
+ expect(row['error_message']).toBe('Too many requests');
269
+ expect(row['retry_after']).toBe(30);
270
+ });
271
+
272
+ it('inserts multiple rows appending each independently', () => {
273
+ const repo = new SqliteClaudeMessageResponseRepository(dbPath);
274
+ const responses = [
275
+ buildTestResponse({ httpStatus: 200 }),
276
+ buildTestResponse({ httpStatus: 429 }),
277
+ buildTestResponse({ httpStatus: 200 }),
278
+ ];
279
+
280
+ for (const response of responses) {
281
+ repo.append(response);
282
+ }
283
+
284
+ const db = new Database(dbPath);
285
+ const rows = requireRecordArray(
286
+ db.prepare('SELECT id FROM claude_message_response').all(),
287
+ );
288
+ db.close();
289
+
290
+ expect(rows).toHaveLength(3);
291
+ const rowIds = rows.map((r) => r['id']).sort();
292
+ const responseIds = responses.map((r) => r.id).sort();
293
+ expect(rowIds).toEqual(responseIds);
294
+ });
295
+
296
+ it('persists rows that survive a repository close and reopen', () => {
297
+ const firstRepo = new SqliteClaudeMessageResponseRepository(dbPath);
298
+ const response = buildTestResponse();
299
+ firstRepo.append(response);
300
+
301
+ const secondRepo = new SqliteClaudeMessageResponseRepository(dbPath);
302
+
303
+ const db = new Database(dbPath);
304
+ const countRow = requireRecord(
305
+ db.prepare('SELECT COUNT(*) as cnt FROM claude_message_response').get(),
306
+ );
307
+ db.close();
308
+
309
+ expect(countRow['cnt']).toBe(1);
310
+ expect(secondRepo).toBeDefined();
311
+ });
312
+ });
313
+ });
@@ -0,0 +1,164 @@
1
+ import * as crypto from 'crypto';
2
+ import Database from 'better-sqlite3';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import { ClaudeMessageResponse } from '../../domain/entities/ClaudeMessageResponse';
6
+ import { ClaudeMessageResponseRepository } from '../../domain/usecases/adapter-interfaces/ClaudeMessageResponseRepository';
7
+
8
+ const ULID_ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
9
+
10
+ const encodeUlidTime = (ms: number): string => {
11
+ let str = '';
12
+ for (let i = 9; i >= 0; i--) {
13
+ str = ULID_ENCODING[ms % 32] + str;
14
+ ms = Math.floor(ms / 32);
15
+ }
16
+ return str;
17
+ };
18
+
19
+ const encodeUlidRandom = (): string => {
20
+ const bytes = crypto.randomBytes(10);
21
+ let bits = 0;
22
+ let bitLen = 0;
23
+ let str = '';
24
+ for (let i = 0; i < bytes.length && str.length < 16; i++) {
25
+ bits = (bits << 8) | bytes[i];
26
+ bitLen += 8;
27
+ while (bitLen >= 5 && str.length < 16) {
28
+ str += ULID_ENCODING[(bits >>> (bitLen - 5)) & 0x1f];
29
+ bitLen -= 5;
30
+ }
31
+ }
32
+ return str;
33
+ };
34
+
35
+ export const generateUlid = (): string =>
36
+ encodeUlidTime(Date.now()) + encodeUlidRandom();
37
+
38
+ const CREATE_TABLE_SQL = `
39
+ CREATE TABLE IF NOT EXISTS claude_message_response (
40
+ id TEXT PRIMARY KEY,
41
+ observed_at INTEGER NOT NULL,
42
+ token_name TEXT NOT NULL,
43
+ external_claude_message_id TEXT,
44
+ external_claude_request_id TEXT,
45
+ http_status INTEGER NOT NULL,
46
+ model TEXT,
47
+ role TEXT,
48
+ stop_reason TEXT,
49
+ stop_sequence TEXT,
50
+ input_tokens INTEGER,
51
+ output_tokens INTEGER,
52
+ cache_creation_input_tokens INTEGER,
53
+ cache_read_input_tokens INTEGER,
54
+ ephemeral_5m_input_tokens INTEGER,
55
+ ephemeral_1h_input_tokens INTEGER,
56
+ service_tier TEXT,
57
+ inference_geo TEXT,
58
+ error_type TEXT,
59
+ error_message TEXT,
60
+ anthropic_ratelimit_unified_status TEXT,
61
+ anthropic_ratelimit_unified_5h_status TEXT,
62
+ anthropic_ratelimit_unified_5h_utilization REAL,
63
+ anthropic_ratelimit_unified_5h_reset REAL,
64
+ anthropic_ratelimit_unified_7d_status TEXT,
65
+ anthropic_ratelimit_unified_7d_utilization REAL,
66
+ anthropic_ratelimit_unified_7d_reset REAL,
67
+ retry_after REAL,
68
+ anthropic_organization_id TEXT
69
+ ) STRICT
70
+ `;
71
+
72
+ const INSERT_SQL = `
73
+ INSERT INTO claude_message_response (
74
+ id, observed_at, token_name,
75
+ external_claude_message_id, external_claude_request_id,
76
+ http_status, model, role, stop_reason, stop_sequence,
77
+ input_tokens, output_tokens,
78
+ cache_creation_input_tokens, cache_read_input_tokens,
79
+ ephemeral_5m_input_tokens, ephemeral_1h_input_tokens,
80
+ service_tier, inference_geo,
81
+ error_type, error_message,
82
+ anthropic_ratelimit_unified_status,
83
+ anthropic_ratelimit_unified_5h_status,
84
+ anthropic_ratelimit_unified_5h_utilization,
85
+ anthropic_ratelimit_unified_5h_reset,
86
+ anthropic_ratelimit_unified_7d_status,
87
+ anthropic_ratelimit_unified_7d_utilization,
88
+ anthropic_ratelimit_unified_7d_reset,
89
+ retry_after, anthropic_organization_id
90
+ ) VALUES (
91
+ @id, @observedAt, @tokenName,
92
+ @externalClaudeMessageId, @externalClaudeRequestId,
93
+ @httpStatus, @model, @role, @stopReason, @stopSequence,
94
+ @inputTokens, @outputTokens,
95
+ @cacheCreationInputTokens, @cacheReadInputTokens,
96
+ @ephemeral5mInputTokens, @ephemeral1hInputTokens,
97
+ @serviceTier, @inferenceGeo,
98
+ @errorType, @errorMessage,
99
+ @anthropicRatelimitUnifiedStatus,
100
+ @anthropicRatelimitUnified5hStatus,
101
+ @anthropicRatelimitUnified5hUtilization,
102
+ @anthropicRatelimitUnified5hReset,
103
+ @anthropicRatelimitUnified7dStatus,
104
+ @anthropicRatelimitUnified7dUtilization,
105
+ @anthropicRatelimitUnified7dReset,
106
+ @retryAfter, @anthropicOrganizationId
107
+ )
108
+ `;
109
+
110
+ export class SqliteClaudeMessageResponseRepository implements ClaudeMessageResponseRepository {
111
+ private readonly db: Database.Database;
112
+ private readonly insert: Database.Statement;
113
+
114
+ constructor(dbPath: string) {
115
+ const dir = path.dirname(dbPath);
116
+ if (!fs.existsSync(dir)) {
117
+ fs.mkdirSync(dir, { recursive: true });
118
+ }
119
+ this.db = new Database(dbPath);
120
+ this.db.pragma('journal_mode = WAL');
121
+ this.db.exec(CREATE_TABLE_SQL);
122
+ this.insert = this.db.prepare(INSERT_SQL);
123
+ }
124
+
125
+ append = (response: ClaudeMessageResponse): void => {
126
+ this.insert.run({
127
+ id: response.id,
128
+ observedAt: response.observedAt.getTime(),
129
+ tokenName: response.tokenName,
130
+ externalClaudeMessageId: response.externalClaudeMessageId,
131
+ externalClaudeRequestId: response.externalClaudeRequestId,
132
+ httpStatus: response.httpStatus,
133
+ model: response.model,
134
+ role: response.role,
135
+ stopReason: response.stopReason,
136
+ stopSequence: response.stopSequence,
137
+ inputTokens: response.inputTokens,
138
+ outputTokens: response.outputTokens,
139
+ cacheCreationInputTokens: response.cacheCreationInputTokens,
140
+ cacheReadInputTokens: response.cacheReadInputTokens,
141
+ ephemeral5mInputTokens: response.ephemeral5mInputTokens,
142
+ ephemeral1hInputTokens: response.ephemeral1hInputTokens,
143
+ serviceTier: response.serviceTier,
144
+ inferenceGeo: response.inferenceGeo,
145
+ errorType: response.errorType,
146
+ errorMessage: response.errorMessage,
147
+ anthropicRatelimitUnifiedStatus: response.anthropicRatelimitUnifiedStatus,
148
+ anthropicRatelimitUnified5hStatus:
149
+ response.anthropicRatelimitUnified5hStatus,
150
+ anthropicRatelimitUnified5hUtilization:
151
+ response.anthropicRatelimitUnified5hUtilization,
152
+ anthropicRatelimitUnified5hReset:
153
+ response.anthropicRatelimitUnified5hReset,
154
+ anthropicRatelimitUnified7dStatus:
155
+ response.anthropicRatelimitUnified7dStatus,
156
+ anthropicRatelimitUnified7dUtilization:
157
+ response.anthropicRatelimitUnified7dUtilization,
158
+ anthropicRatelimitUnified7dReset:
159
+ response.anthropicRatelimitUnified7dReset,
160
+ retryAfter: response.retryAfter,
161
+ anthropicOrganizationId: response.anthropicOrganizationId,
162
+ });
163
+ };
164
+ }
@@ -0,0 +1,31 @@
1
+ export type ClaudeMessageResponse = {
2
+ id: string;
3
+ observedAt: Date;
4
+ tokenName: string;
5
+ externalClaudeMessageId: string | null;
6
+ externalClaudeRequestId: string | null;
7
+ httpStatus: number;
8
+ model: string | null;
9
+ role: string | null;
10
+ stopReason: string | null;
11
+ stopSequence: string | null;
12
+ inputTokens: number | null;
13
+ outputTokens: number | null;
14
+ cacheCreationInputTokens: number | null;
15
+ cacheReadInputTokens: number | null;
16
+ ephemeral5mInputTokens: number | null;
17
+ ephemeral1hInputTokens: number | null;
18
+ serviceTier: string | null;
19
+ inferenceGeo: string | null;
20
+ errorType: string | null;
21
+ errorMessage: string | null;
22
+ anthropicRatelimitUnifiedStatus: string | null;
23
+ anthropicRatelimitUnified5hStatus: string | null;
24
+ anthropicRatelimitUnified5hUtilization: number | null;
25
+ anthropicRatelimitUnified5hReset: number | null;
26
+ anthropicRatelimitUnified7dStatus: string | null;
27
+ anthropicRatelimitUnified7dUtilization: number | null;
28
+ anthropicRatelimitUnified7dReset: number | null;
29
+ retryAfter: number | null;
30
+ anthropicOrganizationId: string | null;
31
+ };
@@ -23,6 +23,7 @@ import { StartPreparationUseCase } from './StartPreparationUseCase';
23
23
  import { RevertOrphanedPreparationUseCase } from './RevertOrphanedPreparationUseCase';
24
24
  import { RevertNotReadyAwaitingQualityCheckUseCase } from './RevertNotReadyAwaitingQualityCheckUseCase';
25
25
  import { SetupTowerDefenceProjectUseCase } from './SetupTowerDefenceProjectUseCase';
26
+ import { UpdateRateLimitCacheUseCase } from './UpdateRateLimitCacheUseCase';
26
27
 
27
28
  describe('HandleScheduledEventUseCase', () => {
28
29
  describe('createTargetDateTimes', () => {
@@ -112,6 +113,7 @@ describe('HandleScheduledEventUseCase', () => {
112
113
  mock<RevertOrphanedPreparationUseCase>();
113
114
  const mockRevertNotReadyAwaitingQualityCheckUseCase =
114
115
  mock<RevertNotReadyAwaitingQualityCheckUseCase>();
116
+ const mockUpdateRateLimitCacheUseCase = mock<UpdateRateLimitCacheUseCase>();
115
117
  const mockDateRepository = mock<DateRepository>();
116
118
  const mockSpreadsheetRepository = mock<SpreadsheetRepository>();
117
119
  const mockProjectRepository = mock<ProjectRepository>();
@@ -135,6 +137,7 @@ describe('HandleScheduledEventUseCase', () => {
135
137
  mockStartPreparationUseCase,
136
138
  mockRevertOrphanedPreparationUseCase,
137
139
  mockRevertNotReadyAwaitingQualityCheckUseCase,
140
+ mockUpdateRateLimitCacheUseCase,
138
141
  mockDateRepository,
139
142
  mockSpreadsheetRepository,
140
143
  mockProjectRepository,
@@ -367,6 +370,66 @@ describe('HandleScheduledEventUseCase', () => {
367
370
  ).not.toHaveBeenCalled();
368
371
  });
369
372
 
373
+ it('should invoke UpdateRateLimitCacheUseCase before StartPreparationUseCase when startPreparation is configured', async () => {
374
+ const callOrder: string[] = [];
375
+ mockUpdateRateLimitCacheUseCase.run.mockImplementation(async () => {
376
+ callOrder.push('updateRateLimitCache');
377
+ });
378
+ mockStartPreparationUseCase.run.mockImplementation(async () => {
379
+ callOrder.push('startPreparation');
380
+ });
381
+
382
+ const input = {
383
+ projectName: 'test-project',
384
+ org: 'test-org',
385
+ projectUrl: 'https://github.com/test-org/test-project',
386
+ manager: 'test-manager',
387
+ workingReport: {
388
+ repo: 'test-repo',
389
+ members: ['member1'],
390
+ spreadsheetUrl: 'https://docs.google.com/spreadsheets/test',
391
+ },
392
+ urlOfStoryView: 'https://github.com/test-org/test-project/issues',
393
+ disabled: false,
394
+ allowIssueCacheMinutes: 60,
395
+ startPreparation: {
396
+ defaultAgentName: 'aw',
397
+ configFilePath: '/path/to/config.yml',
398
+ maximumPreparingIssuesCount: null,
399
+ },
400
+ };
401
+
402
+ mockProjectRepository.getProject.mockResolvedValue(mock<Project>());
403
+ await useCase.run(input);
404
+
405
+ expect(mockUpdateRateLimitCacheUseCase.run).toHaveBeenCalledTimes(1);
406
+ expect(callOrder.indexOf('updateRateLimitCache')).toBeLessThan(
407
+ callOrder.indexOf('startPreparation'),
408
+ );
409
+ });
410
+
411
+ it('should not invoke UpdateRateLimitCacheUseCase when startPreparation is absent', async () => {
412
+ const input = {
413
+ projectName: 'test-project',
414
+ org: 'test-org',
415
+ projectUrl: 'https://github.com/test-org/test-project',
416
+ manager: 'test-manager',
417
+ workingReport: {
418
+ repo: 'test-repo',
419
+ members: ['member1'],
420
+ spreadsheetUrl: 'https://docs.google.com/spreadsheets/test',
421
+ },
422
+ urlOfStoryView: 'https://github.com/test-org/test-project/issues',
423
+ disabled: false,
424
+ allowIssueCacheMinutes: 60,
425
+ };
426
+
427
+ mockProjectRepository.getProject.mockResolvedValue(mock<Project>());
428
+ await useCase.run(input);
429
+
430
+ expect(mockUpdateRateLimitCacheUseCase.run).not.toHaveBeenCalled();
431
+ });
432
+
370
433
  describe('story issue creation progress logs', () => {
371
434
  const storyInput = {
372
435
  projectName: 'test-project',
@@ -23,6 +23,7 @@ import { StartPreparationUseCase } from './StartPreparationUseCase';
23
23
  import { RevertOrphanedPreparationUseCase } from './RevertOrphanedPreparationUseCase';
24
24
  import { RevertNotReadyAwaitingQualityCheckUseCase } from './RevertNotReadyAwaitingQualityCheckUseCase';
25
25
  import { SetupTowerDefenceProjectUseCase } from './SetupTowerDefenceProjectUseCase';
26
+ import { UpdateRateLimitCacheUseCase } from './UpdateRateLimitCacheUseCase';
26
27
 
27
28
  export class ProjectNotFoundError extends Error {
28
29
  constructor(message: string) {
@@ -52,6 +53,7 @@ export class HandleScheduledEventUseCase {
52
53
  readonly startPreparationUseCase: StartPreparationUseCase,
53
54
  readonly revertOrphanedPreparationUseCase: RevertOrphanedPreparationUseCase,
54
55
  readonly revertNotReadyAwaitingQualityCheckUseCase: RevertNotReadyAwaitingQualityCheckUseCase,
56
+ readonly updateRateLimitCacheUseCase: UpdateRateLimitCacheUseCase | null,
55
57
  readonly dateRepository: DateRepository,
56
58
  readonly spreadsheetRepository: SpreadsheetRepository,
57
59
  readonly projectRepository: ProjectRepository,
@@ -264,6 +266,11 @@ ${JSON.stringify(e)}
264
266
  });
265
267
  }
266
268
  if (input.startPreparation) {
269
+ if (this.updateRateLimitCacheUseCase !== null) {
270
+ await this.updateRateLimitCacheUseCase.run({
271
+ nowEpochSeconds: Date.now() / 1000,
272
+ });
273
+ }
267
274
  if (input.startPreparation.preparationProcessCheckCommand) {
268
275
  await this.revertOrphanedPreparationUseCase.run({
269
276
  projectUrl: input.projectUrl,