github-issue-tower-defence-management 1.104.3 → 1.105.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.
- package/CHANGELOG.md +20 -0
- package/README.md +8 -7
- package/bin/adapter/entry-points/cli/index.js +21 -13
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/{consoleServer.js → webServer.js} +11 -11
- package/bin/adapter/entry-points/console/webServer.js.map +1 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +41 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/dashboardRowWriter.js +34 -0
- package/bin/adapter/entry-points/handlers/dashboardRowWriter.js.map +1 -0
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js +60 -0
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +98 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -0
- package/bin/adapter/proxy/RateLimitCache.js +3 -0
- package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
- package/bin/adapter/repositories/ProcHostMetricsRepository.js +136 -0
- package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -0
- package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js +131 -0
- package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js.map +1 -0
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +93 -8
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
- package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js.map +1 -0
- package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js +38 -0
- package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js.map +1 -0
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +115 -0
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +74 -21
- package/src/adapter/entry-points/cli/index.ts +146 -136
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -2
- package/src/adapter/entry-points/console/{consoleServer.test.ts → webServer.test.ts} +32 -32
- package/src/adapter/entry-points/console/{consoleServer.ts → webServer.ts} +15 -17
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +52 -0
- package/src/adapter/entry-points/handlers/dashboardRowWriter.test.ts +111 -0
- package/src/adapter/entry-points/handlers/dashboardRowWriter.ts +51 -0
- package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +100 -0
- package/src/adapter/entry-points/handlers/machineStatusWriter.ts +79 -0
- package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +176 -0
- package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +139 -0
- package/src/adapter/proxy/RateLimitCache.test.ts +32 -0
- package/src/adapter/proxy/RateLimitCache.ts +6 -0
- package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +135 -0
- package/src/adapter/repositories/ProcHostMetricsRepository.ts +136 -0
- package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.test.ts +130 -0
- package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.ts +118 -0
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +267 -13
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +140 -47
- package/src/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.ts +8 -0
- package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.test.ts +159 -0
- package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.ts +72 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +209 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +195 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/{consoleServer.d.ts → webServer.d.ts} +7 -7
- package/types/adapter/entry-points/console/webServer.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts +15 -0
- package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +16 -0
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts +21 -0
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -0
- package/types/adapter/proxy/RateLimitCache.d.ts +2 -0
- package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +23 -0
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -0
- package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts +11 -0
- package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts.map +1 -0
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +6 -1
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts +8 -0
- package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts.map +1 -0
- package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts +19 -0
- package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts.map +1 -0
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +53 -0
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -0
- package/bin/adapter/entry-points/console/consoleServer.js.map +0 -1
- package/types/adapter/entry-points/console/consoleServer.d.ts.map +0 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import type { Issue } from '../../../domain/entities/Issue';
|
|
4
|
+
import {
|
|
5
|
+
GenerateTokenStatusUseCase,
|
|
6
|
+
TokenRateLimitSnapshot,
|
|
7
|
+
TokenStatus,
|
|
8
|
+
TokenStatusInput,
|
|
9
|
+
} from '../../../domain/usecases/dashboard/GenerateTokenStatusUseCase';
|
|
10
|
+
import { InTmuxByHumanSessionTokenCountUseCase } from '../../../domain/usecases/InTmuxByHumanSessionTokenCountUseCase';
|
|
11
|
+
import { OauthTokenCandidate } from '../../../domain/usecases/OauthTokenSelectUseCase';
|
|
12
|
+
import { ClaudeInteractiveSessionRepository } from '../../../domain/usecases/adapter-interfaces/ClaudeInteractiveSessionRepository';
|
|
13
|
+
import { TakeOwnershipSpawnRepository } from '../../../domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository';
|
|
14
|
+
import { RateLimitSnapshot, readRateLimit } from '../../proxy/RateLimitCache';
|
|
15
|
+
import { loadTokenEntries } from '../../proxy/TokenListLoader';
|
|
16
|
+
import { ProcClaudeInteractiveSessionRepository } from '../../repositories/ProcClaudeInteractiveSessionRepository';
|
|
17
|
+
import { ProcTakeOwnershipSpawnRepository } from '../../repositories/ProcTakeOwnershipSpawnRepository';
|
|
18
|
+
|
|
19
|
+
const SEVEN_DAY_SONNET_LIMIT_TYPE = 'seven_day_sonnet';
|
|
20
|
+
const SEVEN_DAY_OPUS_LIMIT_TYPE = 'seven_day_opus';
|
|
21
|
+
|
|
22
|
+
export type TokenStatusWriterParams = {
|
|
23
|
+
dashboardDataDir: string | null | undefined;
|
|
24
|
+
tokenListJsonPath: string | null | undefined;
|
|
25
|
+
issues: Issue[];
|
|
26
|
+
now?: Date;
|
|
27
|
+
readSnapshot?: (token: string) => RateLimitSnapshot | null;
|
|
28
|
+
interactiveSessionRepository?: ClaudeInteractiveSessionRepository;
|
|
29
|
+
spawnRepository?: TakeOwnershipSpawnRepository;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type TokenStatusFile = {
|
|
33
|
+
tokens: TokenStatus[];
|
|
34
|
+
capturedAt: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const writeJsonAtomic = (filePath: string, data: unknown): void => {
|
|
38
|
+
const dir = path.dirname(filePath);
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
const tmpPath = `${filePath}.tmp`;
|
|
41
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data));
|
|
42
|
+
fs.renameSync(tmpPath, filePath);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const toTokenRateLimitSnapshot = (
|
|
46
|
+
snapshot: RateLimitSnapshot | null,
|
|
47
|
+
): TokenRateLimitSnapshot | null => {
|
|
48
|
+
if (snapshot === null) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const hasWindowData =
|
|
52
|
+
snapshot.unifiedStatus !== null ||
|
|
53
|
+
snapshot.fiveHourReset > 0 ||
|
|
54
|
+
snapshot.sevenDayReset > 0 ||
|
|
55
|
+
snapshot.fiveHourUtilization > 0 ||
|
|
56
|
+
snapshot.sevenDayUtilization > 0;
|
|
57
|
+
return {
|
|
58
|
+
fiveHourUtilization: snapshot.fiveHourUtilization,
|
|
59
|
+
fiveHourReset: snapshot.fiveHourReset,
|
|
60
|
+
sevenDayUtilization: snapshot.sevenDayUtilization,
|
|
61
|
+
sevenDayReset: snapshot.sevenDayReset,
|
|
62
|
+
blocked: snapshot.blocked,
|
|
63
|
+
fiveHourRejected: snapshot.fiveHourRejected,
|
|
64
|
+
sevenDayRejected: snapshot.sevenDayRejected,
|
|
65
|
+
unifiedStatus: snapshot.unifiedStatus,
|
|
66
|
+
sevenDaySonnetRejected:
|
|
67
|
+
snapshot.modelWeeklyLimits[SEVEN_DAY_SONNET_LIMIT_TYPE]?.rejected ??
|
|
68
|
+
false,
|
|
69
|
+
sevenDayOpusRejected:
|
|
70
|
+
snapshot.modelWeeklyLimits[SEVEN_DAY_OPUS_LIMIT_TYPE]?.rejected ?? false,
|
|
71
|
+
hasWindowData,
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const writeTokenStatus = (params: TokenStatusWriterParams): void => {
|
|
76
|
+
const { dashboardDataDir, tokenListJsonPath, issues } = params;
|
|
77
|
+
if (!dashboardDataDir || !tokenListJsonPath) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const entries = loadTokenEntries(tokenListJsonPath);
|
|
82
|
+
if (entries === null) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const readSnapshot = params.readSnapshot ?? readRateLimit;
|
|
87
|
+
const interactiveSessionRepository =
|
|
88
|
+
params.interactiveSessionRepository ??
|
|
89
|
+
new ProcClaudeInteractiveSessionRepository();
|
|
90
|
+
const spawnRepository =
|
|
91
|
+
params.spawnRepository ?? new ProcTakeOwnershipSpawnRepository();
|
|
92
|
+
|
|
93
|
+
const tokenInputs: TokenStatusInput[] = entries.map((entry) => ({
|
|
94
|
+
name: entry.name,
|
|
95
|
+
token: entry.token,
|
|
96
|
+
snapshot: toTokenRateLimitSnapshot(readSnapshot(entry.token)),
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const distinctLogPathsByToken = new Map<string, Set<string>>();
|
|
100
|
+
for (const spawn of spawnRepository.listSpawns()) {
|
|
101
|
+
const logPaths =
|
|
102
|
+
distinctLogPathsByToken.get(spawn.token) ?? new Set<string>();
|
|
103
|
+
logPaths.add(spawn.logPath);
|
|
104
|
+
distinctLogPathsByToken.set(spawn.token, logPaths);
|
|
105
|
+
}
|
|
106
|
+
const prepCountByToken = new Map<string, number>();
|
|
107
|
+
for (const [token, logPaths] of distinctLogPathsByToken.entries()) {
|
|
108
|
+
prepCountByToken.set(token, logPaths.size);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const candidates: OauthTokenCandidate[] = entries.map((entry) => ({
|
|
112
|
+
name: entry.name,
|
|
113
|
+
token: entry.token,
|
|
114
|
+
snapshot: null,
|
|
115
|
+
}));
|
|
116
|
+
const humResult = new InTmuxByHumanSessionTokenCountUseCase().run(
|
|
117
|
+
candidates,
|
|
118
|
+
interactiveSessionRepository.listInteractiveSessions(),
|
|
119
|
+
issues,
|
|
120
|
+
);
|
|
121
|
+
const humCountByToken = new Map<string, number>(
|
|
122
|
+
humResult.counts.map((count) => [count.token, count.count]),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const now = params.now ?? new Date();
|
|
126
|
+
const tokens = new GenerateTokenStatusUseCase().run({
|
|
127
|
+
tokens: tokenInputs,
|
|
128
|
+
prepCountByToken,
|
|
129
|
+
humCountByToken,
|
|
130
|
+
nowEpochSeconds: Math.floor(now.getTime() / 1000),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const file: TokenStatusFile = {
|
|
134
|
+
tokens,
|
|
135
|
+
capturedAt: now.toISOString(),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
writeJsonAtomic(path.join(dashboardDataDir, 'token-status.json'), file);
|
|
139
|
+
};
|
|
@@ -180,6 +180,38 @@ describe('RateLimitCache', () => {
|
|
|
180
180
|
expect(snapshot?.sevenDayRejected).toBe(false);
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
+
it('should surface the raw unified status and overage-disabled reason headers', () => {
|
|
184
|
+
const token = 'unified-status-token';
|
|
185
|
+
writeRateLimit(token, {
|
|
186
|
+
'anthropic-ratelimit-unified-status': 'allowed_warning',
|
|
187
|
+
'anthropic-ratelimit-unified-overage-disabled-reason': 'out_of_credits',
|
|
188
|
+
'anthropic-ratelimit-unified-5h-status': 'allowed',
|
|
189
|
+
'anthropic-ratelimit-unified-5h-reset': '1700000000',
|
|
190
|
+
'anthropic-ratelimit-unified-5h-utilization': '50',
|
|
191
|
+
'anthropic-ratelimit-unified-7d-status': 'allowed',
|
|
192
|
+
'anthropic-ratelimit-unified-7d-reset': '1700100000',
|
|
193
|
+
'anthropic-ratelimit-unified-7d-utilization': '40',
|
|
194
|
+
});
|
|
195
|
+
const snapshot = readRateLimit(token);
|
|
196
|
+
expect(snapshot?.unifiedStatus).toBe('allowed_warning');
|
|
197
|
+
expect(snapshot?.overageDisabledReason).toBe('out_of_credits');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should expose null for the unified status and overage-disabled reason when absent', () => {
|
|
201
|
+
const token = 'no-unified-status-token';
|
|
202
|
+
writeRateLimit(token, {
|
|
203
|
+
'anthropic-ratelimit-unified-5h-status': 'allowed',
|
|
204
|
+
'anthropic-ratelimit-unified-5h-reset': '1700000000',
|
|
205
|
+
'anthropic-ratelimit-unified-5h-utilization': '50',
|
|
206
|
+
'anthropic-ratelimit-unified-7d-status': 'allowed',
|
|
207
|
+
'anthropic-ratelimit-unified-7d-reset': '1700100000',
|
|
208
|
+
'anthropic-ratelimit-unified-7d-utilization': '40',
|
|
209
|
+
});
|
|
210
|
+
const snapshot = readRateLimit(token);
|
|
211
|
+
expect(snapshot?.unifiedStatus).toBeNull();
|
|
212
|
+
expect(snapshot?.overageDisabledReason).toBeNull();
|
|
213
|
+
});
|
|
214
|
+
|
|
183
215
|
it('should return null when file does not exist', () => {
|
|
184
216
|
expect(readRateLimit('never-written-token')).toBeNull();
|
|
185
217
|
});
|
|
@@ -18,6 +18,8 @@ export interface RateLimitSnapshot {
|
|
|
18
18
|
unifiedRejected: boolean;
|
|
19
19
|
fiveHourRejected: boolean;
|
|
20
20
|
sevenDayRejected: boolean;
|
|
21
|
+
unifiedStatus: string | null;
|
|
22
|
+
overageDisabledReason: string | null;
|
|
21
23
|
modelWeeklyLimits: Record<string, ModelWeeklyLimit>;
|
|
22
24
|
lastUpdatedEpoch: number;
|
|
23
25
|
blockedUntilEpoch: number;
|
|
@@ -252,6 +254,8 @@ export const readRateLimit = (
|
|
|
252
254
|
const status = headers['anthropic-ratelimit-unified-status'];
|
|
253
255
|
const fiveHourStatus = headers['anthropic-ratelimit-unified-5h-status'];
|
|
254
256
|
const sevenDayStatus = headers['anthropic-ratelimit-unified-7d-status'];
|
|
257
|
+
const overageDisabledReason =
|
|
258
|
+
headers['anthropic-ratelimit-unified-overage-disabled-reason'];
|
|
255
259
|
const unifiedRejected = status === 'rejected';
|
|
256
260
|
const fiveHourRejected = fiveHourStatus === 'rejected';
|
|
257
261
|
const sevenDayRejected = sevenDayStatus === 'rejected';
|
|
@@ -273,6 +277,8 @@ export const readRateLimit = (
|
|
|
273
277
|
unifiedRejected,
|
|
274
278
|
fiveHourRejected,
|
|
275
279
|
sevenDayRejected,
|
|
280
|
+
unifiedStatus: status ?? null,
|
|
281
|
+
overageDisabledReason: overageDisabledReason ?? null,
|
|
276
282
|
modelWeeklyLimits: {
|
|
277
283
|
...parseModelRateLimitsFromHeaders(headers),
|
|
278
284
|
...readModelWeeklyLimits(parsed),
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
ProcHostMetricsRepository,
|
|
6
|
+
cpuUsedPercentFromSamples,
|
|
7
|
+
cycleMinutesFromMtimes,
|
|
8
|
+
parseCpuSample,
|
|
9
|
+
parseLoadAverages,
|
|
10
|
+
parseMemoryUsedPercent,
|
|
11
|
+
} from './ProcHostMetricsRepository';
|
|
12
|
+
|
|
13
|
+
describe('parseMemoryUsedPercent', () => {
|
|
14
|
+
it('computes (total - available) / total rounded to a whole percent', () => {
|
|
15
|
+
expect(
|
|
16
|
+
parseMemoryUsedPercent(
|
|
17
|
+
'MemTotal: 1000 kB\nMemFree: 100 kB\nMemAvailable: 380 kB\n',
|
|
18
|
+
),
|
|
19
|
+
).toBe(62);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('throws when MemTotal is not positive', () => {
|
|
23
|
+
expect(() =>
|
|
24
|
+
parseMemoryUsedPercent('MemTotal: 0 kB\nMemAvailable: 0 kB\n'),
|
|
25
|
+
).toThrow();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('parseCpuSample', () => {
|
|
30
|
+
it('uses the aggregate cpu line with idle = idle + iowait and total = sum of all fields', () => {
|
|
31
|
+
expect(
|
|
32
|
+
parseCpuSample('cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1 2 3 4\n'),
|
|
33
|
+
).toEqual({ total: 1000, idle: 800 });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('throws when there is no aggregate cpu line', () => {
|
|
37
|
+
expect(() => parseCpuSample('cpu0 1 2 3 4\n')).toThrow();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('cpuUsedPercentFromSamples', () => {
|
|
42
|
+
it('computes (total_delta - idle_delta) / total_delta', () => {
|
|
43
|
+
expect(
|
|
44
|
+
cpuUsedPercentFromSamples(
|
|
45
|
+
{ total: 1000, idle: 700 },
|
|
46
|
+
{ total: 1100, idle: 770 },
|
|
47
|
+
),
|
|
48
|
+
).toBe(30);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('throws when the total delta is not positive', () => {
|
|
52
|
+
expect(() =>
|
|
53
|
+
cpuUsedPercentFromSamples(
|
|
54
|
+
{ total: 1000, idle: 700 },
|
|
55
|
+
{ total: 1000, idle: 700 },
|
|
56
|
+
),
|
|
57
|
+
).toThrow();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('parseLoadAverages', () => {
|
|
62
|
+
it('parses the first three floats from /proc/loadavg', () => {
|
|
63
|
+
expect(parseLoadAverages('1.20 0.98 0.75 1/123 4567\n')).toEqual({
|
|
64
|
+
oneMinute: 1.2,
|
|
65
|
+
fiveMinute: 0.98,
|
|
66
|
+
fifteenMinute: 0.75,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('throws when fewer than three numeric values are present', () => {
|
|
71
|
+
expect(() => parseLoadAverages('1.20 0.98\n')).toThrow();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('cycleMinutesFromMtimes', () => {
|
|
76
|
+
it('rounds the gap between the two newest generations to whole minutes', () => {
|
|
77
|
+
expect(cycleMinutesFromMtimes([1782469254.0, 1782468443.0])).toBe(14);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rounds a 1620 second gap to 27 minutes', () => {
|
|
81
|
+
expect(cycleMinutesFromMtimes([1620.0, 0.0])).toBe(27);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns null when a second generation is missing', () => {
|
|
85
|
+
expect(cycleMinutesFromMtimes([1000.0])).toBeNull();
|
|
86
|
+
expect(cycleMinutesFromMtimes([])).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('ProcHostMetricsRepository', () => {
|
|
91
|
+
let procDirectory: string;
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
procDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-proc-host-'));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
fs.rmSync(procDirectory, { recursive: true, force: true });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('reads memory used percent from /proc/meminfo', () => {
|
|
102
|
+
fs.writeFileSync(
|
|
103
|
+
path.join(procDirectory, 'meminfo'),
|
|
104
|
+
'MemTotal: 1000 kB\nMemAvailable: 380 kB\n',
|
|
105
|
+
);
|
|
106
|
+
const repository = new ProcHostMetricsRepository(procDirectory);
|
|
107
|
+
expect(repository.readMemoryUsedPercent()).toBe(62);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('reads load averages from /proc/loadavg', () => {
|
|
111
|
+
fs.writeFileSync(
|
|
112
|
+
path.join(procDirectory, 'loadavg'),
|
|
113
|
+
'2.00 1.00 0.50 1/100 200\n',
|
|
114
|
+
);
|
|
115
|
+
const repository = new ProcHostMetricsRepository(procDirectory);
|
|
116
|
+
expect(repository.readLoadAverages()).toEqual({
|
|
117
|
+
oneMinute: 2.0,
|
|
118
|
+
fiveMinute: 1.0,
|
|
119
|
+
fifteenMinute: 0.5,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('samples /proc/stat twice and computes the busy percent', async () => {
|
|
124
|
+
const statPath = path.join(procDirectory, 'stat');
|
|
125
|
+
let sampleIndex = 0;
|
|
126
|
+
fs.writeFileSync(statPath, 'cpu 700 0 0 300 0 0 0 0 0 0\n');
|
|
127
|
+
const sleep = async (): Promise<void> => {
|
|
128
|
+
sampleIndex += 1;
|
|
129
|
+
fs.writeFileSync(statPath, 'cpu 770 0 0 330 0 0 0 0 0 0\n');
|
|
130
|
+
};
|
|
131
|
+
const repository = new ProcHostMetricsRepository(procDirectory, sleep);
|
|
132
|
+
expect(await repository.readCpuUsedPercent()).toBe(70);
|
|
133
|
+
expect(sampleIndex).toBe(1);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
export type CpuSample = {
|
|
5
|
+
total: number;
|
|
6
|
+
idle: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type LoadAverages = {
|
|
10
|
+
oneMinute: number;
|
|
11
|
+
fiveMinute: number;
|
|
12
|
+
fifteenMinute: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const DEFAULT_PROC_DIRECTORY = '/proc';
|
|
16
|
+
const CPU_SAMPLE_INTERVAL_MS = 400;
|
|
17
|
+
|
|
18
|
+
export const parseMemoryUsedPercent = (meminfoText: string): number => {
|
|
19
|
+
const fields = new Map<string, number>();
|
|
20
|
+
for (const line of meminfoText.split('\n')) {
|
|
21
|
+
const separatorIndex = line.indexOf(':');
|
|
22
|
+
if (separatorIndex <= 0) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
26
|
+
const valuePart = line
|
|
27
|
+
.slice(separatorIndex + 1)
|
|
28
|
+
.trim()
|
|
29
|
+
.split(/\s+/)[0];
|
|
30
|
+
const value = Number(valuePart);
|
|
31
|
+
if (Number.isFinite(value)) {
|
|
32
|
+
fields.set(key, value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const total = fields.get('MemTotal');
|
|
36
|
+
const available = fields.get('MemAvailable');
|
|
37
|
+
if (total === undefined || available === undefined) {
|
|
38
|
+
throw new Error('MemTotal and MemAvailable are required');
|
|
39
|
+
}
|
|
40
|
+
if (total <= 0) {
|
|
41
|
+
throw new Error('MemTotal must be positive');
|
|
42
|
+
}
|
|
43
|
+
const used = total - available;
|
|
44
|
+
return Math.round((used / total) * 100);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const parseCpuSample = (statText: string): CpuSample => {
|
|
48
|
+
for (const line of statText.split('\n')) {
|
|
49
|
+
if (line.startsWith('cpu ')) {
|
|
50
|
+
const values = line
|
|
51
|
+
.trim()
|
|
52
|
+
.split(/\s+/)
|
|
53
|
+
.slice(1)
|
|
54
|
+
.map((value) => Number(value));
|
|
55
|
+
if (values.some((value) => !Number.isFinite(value))) {
|
|
56
|
+
throw new Error('aggregate cpu line contains non-numeric values');
|
|
57
|
+
}
|
|
58
|
+
const idle = values[3] + (values.length > 4 ? values[4] : 0);
|
|
59
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
60
|
+
return { total, idle };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
throw new Error('aggregate cpu line not found');
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const cpuUsedPercentFromSamples = (
|
|
67
|
+
first: CpuSample,
|
|
68
|
+
second: CpuSample,
|
|
69
|
+
): number => {
|
|
70
|
+
const totalDelta = second.total - first.total;
|
|
71
|
+
const idleDelta = second.idle - first.idle;
|
|
72
|
+
if (totalDelta <= 0) {
|
|
73
|
+
throw new Error('total delta must be positive');
|
|
74
|
+
}
|
|
75
|
+
const busyDelta = totalDelta - idleDelta;
|
|
76
|
+
return Math.round((busyDelta / totalDelta) * 100);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const parseLoadAverages = (loadavgText: string): LoadAverages => {
|
|
80
|
+
const parts = loadavgText.trim().split(/\s+/);
|
|
81
|
+
const oneMinute = Number(parts[0]);
|
|
82
|
+
const fiveMinute = Number(parts[1]);
|
|
83
|
+
const fifteenMinute = Number(parts[2]);
|
|
84
|
+
if (
|
|
85
|
+
!Number.isFinite(oneMinute) ||
|
|
86
|
+
!Number.isFinite(fiveMinute) ||
|
|
87
|
+
!Number.isFinite(fifteenMinute)
|
|
88
|
+
) {
|
|
89
|
+
throw new Error('loadavg must contain three numeric values');
|
|
90
|
+
}
|
|
91
|
+
return { oneMinute, fiveMinute, fifteenMinute };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const cycleMinutesFromMtimes = (
|
|
95
|
+
mtimesDescendingSeconds: number[],
|
|
96
|
+
): number | null => {
|
|
97
|
+
if (mtimesDescendingSeconds.length < 2) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return Math.round(
|
|
101
|
+
(mtimesDescendingSeconds[0] - mtimesDescendingSeconds[1]) / 60,
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export class ProcHostMetricsRepository {
|
|
106
|
+
constructor(
|
|
107
|
+
private readonly procDirectory: string = DEFAULT_PROC_DIRECTORY,
|
|
108
|
+
private readonly sleep: (milliseconds: number) => Promise<void> = (
|
|
109
|
+
milliseconds,
|
|
110
|
+
) =>
|
|
111
|
+
new Promise((resolve) => {
|
|
112
|
+
setTimeout(resolve, milliseconds);
|
|
113
|
+
}),
|
|
114
|
+
) {}
|
|
115
|
+
|
|
116
|
+
readMemoryUsedPercent = (): number =>
|
|
117
|
+
parseMemoryUsedPercent(
|
|
118
|
+
fs.readFileSync(path.join(this.procDirectory, 'meminfo'), 'utf8'),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
readCpuUsedPercent = async (): Promise<number> => {
|
|
122
|
+
const first = parseCpuSample(
|
|
123
|
+
fs.readFileSync(path.join(this.procDirectory, 'stat'), 'utf8'),
|
|
124
|
+
);
|
|
125
|
+
await this.sleep(CPU_SAMPLE_INTERVAL_MS);
|
|
126
|
+
const second = parseCpuSample(
|
|
127
|
+
fs.readFileSync(path.join(this.procDirectory, 'stat'), 'utf8'),
|
|
128
|
+
);
|
|
129
|
+
return cpuUsedPercentFromSamples(first, second);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
readLoadAverages = (): LoadAverages =>
|
|
133
|
+
parseLoadAverages(
|
|
134
|
+
fs.readFileSync(path.join(this.procDirectory, 'loadavg'), 'utf8'),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { ProcTakeOwnershipSpawnRepository } from './ProcTakeOwnershipSpawnRepository';
|
|
5
|
+
|
|
6
|
+
type FakeProcess = {
|
|
7
|
+
pid: number;
|
|
8
|
+
cmdline: string;
|
|
9
|
+
environ: Record<string, string>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const issueUrl = 'https://github.com/HiromiShikata/example/issues/1';
|
|
13
|
+
const argv = (...parts: string[]): string => `${parts.join('\0')}\0`;
|
|
14
|
+
|
|
15
|
+
describe('ProcTakeOwnershipSpawnRepository', () => {
|
|
16
|
+
let procDirectory: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
procDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-proc-spawn-'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
fs.rmSync(procDirectory, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const writeProcess = (fakeProcess: FakeProcess): void => {
|
|
27
|
+
const processDirectory = path.join(procDirectory, String(fakeProcess.pid));
|
|
28
|
+
fs.mkdirSync(processDirectory, { recursive: true });
|
|
29
|
+
fs.writeFileSync(
|
|
30
|
+
path.join(processDirectory, 'cmdline'),
|
|
31
|
+
fakeProcess.cmdline,
|
|
32
|
+
);
|
|
33
|
+
const environBuffer = Object.entries(fakeProcess.environ)
|
|
34
|
+
.map(([key, value]) => `${key}=${value}\0`)
|
|
35
|
+
.join('');
|
|
36
|
+
fs.writeFileSync(path.join(processDirectory, 'environ'), environBuffer);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
it('reads the token and log path of a Take ownership spawn', () => {
|
|
40
|
+
writeProcess({
|
|
41
|
+
pid: 301,
|
|
42
|
+
cmdline: argv(
|
|
43
|
+
'bash',
|
|
44
|
+
'-c',
|
|
45
|
+
`claude-agent -p "Take ownership of ${issueUrl}" | tee /home/user/logs-aw/20260626_120000_abc.log`,
|
|
46
|
+
),
|
|
47
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-a' },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
|
|
51
|
+
|
|
52
|
+
expect(repository.listSpawns()).toEqual([
|
|
53
|
+
{ token: 'token-a', logPath: '/logs-aw/20260626_120000_abc.log' },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('ignores a process that is not a Take ownership spawn', () => {
|
|
58
|
+
writeProcess({
|
|
59
|
+
pid: 302,
|
|
60
|
+
cmdline: argv('claude', '--name', issueUrl),
|
|
61
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-b' },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
|
|
65
|
+
|
|
66
|
+
expect(repository.listSpawns()).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('ignores a Take ownership process whose cmdline carries no log path', () => {
|
|
70
|
+
writeProcess({
|
|
71
|
+
pid: 303,
|
|
72
|
+
cmdline: argv('claude-agent', '-p', `Take ownership of ${issueUrl}`),
|
|
73
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-c' },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
|
|
77
|
+
|
|
78
|
+
expect(repository.listSpawns()).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('ignores a Take ownership process without an oauth token', () => {
|
|
82
|
+
writeProcess({
|
|
83
|
+
pid: 304,
|
|
84
|
+
cmdline: argv(
|
|
85
|
+
'bash',
|
|
86
|
+
'-c',
|
|
87
|
+
`Take ownership of ${issueUrl} | tee /home/user/logs-aw/x.log`,
|
|
88
|
+
),
|
|
89
|
+
environ: { ANTHROPIC_API_KEY: 'api-key' },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
|
|
93
|
+
|
|
94
|
+
expect(repository.listSpawns()).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns one entry per child process so the use case can dedupe by log path', () => {
|
|
98
|
+
const fullLogPath = '/home/user/logs-aw/20260626_120000_dup.log';
|
|
99
|
+
const capturedLogPath = '/logs-aw/20260626_120000_dup.log';
|
|
100
|
+
writeProcess({
|
|
101
|
+
pid: 305,
|
|
102
|
+
cmdline: argv(
|
|
103
|
+
'bash',
|
|
104
|
+
'-c',
|
|
105
|
+
`Take ownership of ${issueUrl} | tee ${fullLogPath}`,
|
|
106
|
+
),
|
|
107
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-d' },
|
|
108
|
+
});
|
|
109
|
+
writeProcess({
|
|
110
|
+
pid: 306,
|
|
111
|
+
cmdline: argv('tee', fullLogPath, 'Take ownership marker'),
|
|
112
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-d' },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
|
|
116
|
+
|
|
117
|
+
expect(repository.listSpawns()).toEqual([
|
|
118
|
+
{ token: 'token-d', logPath: capturedLogPath },
|
|
119
|
+
{ token: 'token-d', logPath: capturedLogPath },
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('returns an empty list when the proc directory does not exist', () => {
|
|
124
|
+
const repository = new ProcTakeOwnershipSpawnRepository(
|
|
125
|
+
path.join(procDirectory, 'missing'),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(repository.listSpawns()).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
});
|