github-issue-tower-defence-management 1.104.4 → 1.106.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 +54 -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/staleTmuxSessionCleaner.js +18 -0
- package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.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/NodeTmuxSessionRepository.js +22 -0
- package/bin/adapter/repositories/NodeTmuxSessionRepository.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/domain/entities/LiveTmuxSession.js +3 -0
- package/bin/domain/entities/LiveTmuxSession.js.map +1 -0
- package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js +58 -0
- package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js.map +1 -0
- 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 +69 -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/staleTmuxSessionCleaner.test.ts +170 -0
- package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.ts +40 -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/NodeTmuxSessionRepository.test.ts +81 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.ts +35 -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/domain/entities/LiveTmuxSession.ts +4 -0
- package/src/domain/usecases/StaleTmuxSessionKillUseCase.test.ts +286 -0
- package/src/domain/usecases/StaleTmuxSessionKillUseCase.ts +102 -0
- package/src/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.ts +8 -0
- package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +4 -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/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +6 -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/staleTmuxSessionCleaner.d.ts +12 -0
- package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.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/NodeTmuxSessionRepository.d.ts +3 -0
- package/types/adapter/repositories/NodeTmuxSessionRepository.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/domain/entities/LiveTmuxSession.d.ts +5 -0
- package/types/domain/entities/LiveTmuxSession.d.ts.map +1 -0
- package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts +19 -0
- package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts.map +1 -0
- 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/adapter-interfaces/TmuxSessionRepository.d.ts +3 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
2
2
|
import { TmuxSessionRepository } from '../../domain/usecases/adapter-interfaces/TmuxSessionRepository';
|
|
3
|
+
import { LiveTmuxSession } from '../../domain/entities/LiveTmuxSession';
|
|
3
4
|
|
|
4
5
|
export class NodeTmuxSessionRepository implements TmuxSessionRepository {
|
|
5
6
|
constructor(private readonly localCommandRunner: LocalCommandRunner) {}
|
|
@@ -18,6 +19,26 @@ export class NodeTmuxSessionRepository implements TmuxSessionRepository {
|
|
|
18
19
|
.filter((line) => line.length > 0);
|
|
19
20
|
};
|
|
20
21
|
|
|
22
|
+
listLiveSessionsWithActivity = async (): Promise<LiveTmuxSession[]> => {
|
|
23
|
+
const { stdout, exitCode } = await this.localCommandRunner.runCommand(
|
|
24
|
+
'tmux',
|
|
25
|
+
['list-sessions', '-F', '#{session_name} #{session_activity}'],
|
|
26
|
+
);
|
|
27
|
+
if (exitCode !== 0) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
return stdout
|
|
31
|
+
.split('\n')
|
|
32
|
+
.map((line) => line.trim())
|
|
33
|
+
.filter((line) => line.length > 0)
|
|
34
|
+
.map((line) => {
|
|
35
|
+
const separatorIndex = line.lastIndexOf(' ');
|
|
36
|
+
const sessionName = line.slice(0, separatorIndex);
|
|
37
|
+
const activityEpochSeconds = Number(line.slice(separatorIndex + 1));
|
|
38
|
+
return { sessionName, activityEpochSeconds };
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
21
42
|
listInteractiveProcessCommandLines = async (): Promise<string[]> => {
|
|
22
43
|
const { stdout, exitCode } = await this.localCommandRunner.runCommand(
|
|
23
44
|
'ps',
|
|
@@ -51,4 +72,18 @@ export class NodeTmuxSessionRepository implements TmuxSessionRepository {
|
|
|
51
72
|
issueUrl,
|
|
52
73
|
]);
|
|
53
74
|
};
|
|
75
|
+
|
|
76
|
+
killSession = async (sessionName: string): Promise<void> => {
|
|
77
|
+
const { stderr, exitCode } = await this.localCommandRunner.runCommand(
|
|
78
|
+
'tmux',
|
|
79
|
+
['kill-session', '-t', sessionName],
|
|
80
|
+
);
|
|
81
|
+
if (exitCode !== 0) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Failed to kill tmux session "${sessionName}": exit code ${exitCode}${
|
|
84
|
+
stderr ? `: ${stderr}` : ''
|
|
85
|
+
}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
54
89
|
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import {
|
|
4
|
+
TakeOwnershipSpawn,
|
|
5
|
+
TakeOwnershipSpawnRepository,
|
|
6
|
+
} from '../../domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PROC_DIRECTORY = '/proc';
|
|
9
|
+
const OAUTH_TOKEN_ENVIRON_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
|
|
10
|
+
const TAKE_OWNERSHIP_MARKER = 'Take ownership';
|
|
11
|
+
const LOG_PATH_PATTERN = /\/logs-aw\/[^\0"]+\.log/;
|
|
12
|
+
|
|
13
|
+
const parseCommandLineArguments = (cmdline: string): string[] =>
|
|
14
|
+
cmdline.split('\0').filter((argument) => argument.length > 0);
|
|
15
|
+
|
|
16
|
+
const isTakeOwnershipSpawn = (commandArguments: string[]): boolean =>
|
|
17
|
+
commandArguments.some((argument) => argument.includes(TAKE_OWNERSHIP_MARKER));
|
|
18
|
+
|
|
19
|
+
const extractLogPath = (cmdline: string): string | null => {
|
|
20
|
+
const match = cmdline.match(LOG_PATH_PATTERN);
|
|
21
|
+
return match === null ? null : match[0];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export class ProcTakeOwnershipSpawnRepository implements TakeOwnershipSpawnRepository {
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly procDirectory: string = DEFAULT_PROC_DIRECTORY,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
listSpawns = (): TakeOwnershipSpawn[] => {
|
|
30
|
+
const spawns: TakeOwnershipSpawn[] = [];
|
|
31
|
+
for (const pidDirectory of this.listProcessIdDirectories()) {
|
|
32
|
+
const spawn = this.readSpawn(pidDirectory);
|
|
33
|
+
if (spawn !== null) {
|
|
34
|
+
spawns.push(spawn);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return spawns;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
private listProcessIdDirectories = (): string[] => {
|
|
41
|
+
let entries: string[];
|
|
42
|
+
try {
|
|
43
|
+
entries = fs.readdirSync(this.procDirectory);
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return entries.filter((entry) => /^\d+$/.test(entry));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
private readSpawn = (
|
|
51
|
+
processIdDirectory: string,
|
|
52
|
+
): TakeOwnershipSpawn | null => {
|
|
53
|
+
const rawCmdline = this.readRawCmdline(processIdDirectory);
|
|
54
|
+
if (rawCmdline === null) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const commandArguments = parseCommandLineArguments(rawCmdline);
|
|
58
|
+
if (!isTakeOwnershipSpawn(commandArguments)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const logPath = extractLogPath(rawCmdline);
|
|
62
|
+
if (logPath === null) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const environ = this.readEnviron(processIdDirectory);
|
|
66
|
+
if (environ === null) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const token = environ.get(OAUTH_TOKEN_ENVIRON_KEY);
|
|
70
|
+
if (token === undefined || token.length === 0) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return { token, logPath };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
private readRawCmdline = (processIdDirectory: string): string | null => {
|
|
77
|
+
const cmdlinePath = path.join(
|
|
78
|
+
this.procDirectory,
|
|
79
|
+
processIdDirectory,
|
|
80
|
+
'cmdline',
|
|
81
|
+
);
|
|
82
|
+
try {
|
|
83
|
+
return fs.readFileSync(cmdlinePath, 'utf8');
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
private readEnviron = (
|
|
90
|
+
processIdDirectory: string,
|
|
91
|
+
): Map<string, string> | null => {
|
|
92
|
+
const environPath = path.join(
|
|
93
|
+
this.procDirectory,
|
|
94
|
+
processIdDirectory,
|
|
95
|
+
'environ',
|
|
96
|
+
);
|
|
97
|
+
let raw: string;
|
|
98
|
+
try {
|
|
99
|
+
raw = fs.readFileSync(environPath, 'utf8');
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const environ = new Map<string, string>();
|
|
104
|
+
for (const entry of raw.split('\0')) {
|
|
105
|
+
if (entry.length === 0) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const separatorIndex = entry.indexOf('=');
|
|
109
|
+
if (separatorIndex <= 0) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const key = entry.slice(0, separatorIndex);
|
|
113
|
+
const value = entry.slice(separatorIndex + 1);
|
|
114
|
+
environ.set(key, value);
|
|
115
|
+
}
|
|
116
|
+
return environ;
|
|
117
|
+
};
|
|
118
|
+
}
|