github-issue-tower-defence-management 1.120.0 → 1.121.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 +7 -0
- package/README.md +6 -8
- package/bin/adapter/entry-points/cli/index.js +8 -14
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/cli/projectConfig.js +0 -6
- package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
- package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js +6 -12
- package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -10
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js +23 -19
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -1
- package/bin/adapter/entry-points/handlers/situationFileWriter.js.map +1 -1
- package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js +1 -2
- package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js.map +1 -1
- package/bin/adapter/repositories/LocalStorageCacheRepository.js +25 -0
- package/bin/adapter/repositories/LocalStorageCacheRepository.js.map +1 -1
- package/bin/adapter/repositories/ProcHostMetricsRepository.js +10 -5
- package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +119 -59
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +12 -3
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
- package/bin/domain/usecases/GetStoryObjectMapUseCase.js +1 -1
- package/bin/domain/usecases/GetStoryObjectMapUseCase.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -8
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +2 -2
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js +1 -1
- package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js +1 -1
- package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js.map +1 -1
- package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js +1 -1
- package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js.map +1 -1
- package/bin/domain/usecases/StartPreparationUseCase.js +1 -1
- package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +0 -5
- package/src/adapter/entry-points/cli/index.ts +16 -17
- package/src/adapter/entry-points/cli/projectConfig.ts +0 -8
- package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.test.ts +4 -25
- package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.ts +4 -7
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +2 -25
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +2 -4
- package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +29 -11
- package/src/adapter/entry-points/handlers/machineStatusWriter.ts +30 -19
- package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +0 -5
- package/src/adapter/entry-points/handlers/situationFileWriter.ts +0 -1
- package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +0 -5
- package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.ts +1 -9
- package/src/adapter/repositories/LocalStorageCacheRepository.test.ts +75 -0
- package/src/adapter/repositories/LocalStorageCacheRepository.ts +24 -0
- package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +29 -8
- package/src/adapter/repositories/ProcHostMetricsRepository.ts +10 -6
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +198 -47
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +160 -81
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +23 -3
- package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +7 -12
- package/src/domain/usecases/GetStoryObjectMapUseCase.ts +1 -5
- package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +8 -24
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +6 -17
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +4 -8
- package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +35 -42
- package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.ts +1 -5
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +32 -32
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +1 -5
- package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +17 -4
- package/src/domain/usecases/SetupTowerDefenceProjectUseCase.ts +1 -4
- package/src/domain/usecases/StaleTmuxSessionKillUseCase.test.ts +1 -7
- package/src/domain/usecases/StaleTmuxSessionKillUseCase.ts +1 -5
- package/src/domain/usecases/StartPreparationUseCase.test.ts +1 -94
- package/src/domain/usecases/StartPreparationUseCase.ts +2 -5
- package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +3 -10
- package/types/adapter/entry-points/cli/projectConfig.d.ts +0 -1
- package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
- package/types/adapter/entry-points/function/getStoryObjectMap.d.ts +1 -1
- package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts +1 -1
- package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +1 -0
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/situationFileWriter.d.ts +0 -1
- package/types/adapter/entry-points/handlers/situationFileWriter.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts +0 -1
- package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts.map +1 -1
- package/types/adapter/repositories/LocalStorageCacheRepository.d.ts +2 -0
- package/types/adapter/repositories/LocalStorageCacheRepository.d.ts.map +1 -1
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +1 -1
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +22 -7
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +2 -1
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
- package/types/domain/usecases/GetStoryObjectMapUseCase.d.ts +0 -1
- package/types/domain/usecases/GetStoryObjectMapUseCase.d.ts.map +1 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +0 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts +0 -1
- package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +0 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/SetupTowerDefenceProjectUseCase.d.ts.map +1 -1
- package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts +0 -1
- package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts.map +1 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts +0 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +4 -3
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
|
@@ -53,17 +53,23 @@ describe('writeMachineStatus', () => {
|
|
|
53
53
|
fs.rmSync(procDirectory, { recursive: true, force: true });
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
it('writes machine-status.json with mem, cpu, disk, load and cycle minutes', async () => {
|
|
56
|
+
it('writes machine-status.json with mem, cpu, disk, load and cycle minutes derived from lastFetchedAt', async () => {
|
|
57
57
|
const { repository } = buildHostMetricsRepository(procDirectory);
|
|
58
58
|
const cacheDir = path.join(dir, 'allIssues-PVT');
|
|
59
59
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
60
|
+
fs.writeFileSync(
|
|
61
|
+
path.join(cacheDir, 'latest.json'),
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
lastFetchedAt: '2026-06-26T12:00:00.000Z',
|
|
64
|
+
lastFullFetchAt: '2026-06-26T11:00:00.000Z',
|
|
65
|
+
project: {},
|
|
66
|
+
issues: [],
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
fs.writeFileSync(
|
|
70
|
+
path.join(dir, 'machine-status.json'),
|
|
71
|
+
JSON.stringify({ lastFetchedAt: '2026-06-26T11:45:00.000Z' }),
|
|
72
|
+
);
|
|
67
73
|
|
|
68
74
|
await writeMachineStatus({
|
|
69
75
|
dashboardDataDir: dir,
|
|
@@ -79,16 +85,25 @@ describe('writeMachineStatus', () => {
|
|
|
79
85
|
diskPct: 90,
|
|
80
86
|
load: [1.5, 0.8, 0.4],
|
|
81
87
|
cycleMinutes: 15,
|
|
88
|
+
lastFetchedAt: '2026-06-26T12:00:00.000Z',
|
|
82
89
|
capturedAt: '2026-06-26T12:00:00.000Z',
|
|
83
90
|
};
|
|
84
91
|
expect(written).toEqual(expected);
|
|
85
92
|
});
|
|
86
93
|
|
|
87
|
-
it('writes cycleMinutes null when
|
|
94
|
+
it('writes cycleMinutes null when there is no previous machine-status', async () => {
|
|
88
95
|
const { repository } = buildHostMetricsRepository(procDirectory);
|
|
89
96
|
const cacheDir = path.join(dir, 'allIssues-PVT');
|
|
90
97
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
91
|
-
fs.writeFileSync(
|
|
98
|
+
fs.writeFileSync(
|
|
99
|
+
path.join(cacheDir, 'latest.json'),
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
lastFetchedAt: '2026-06-26T12:00:00.000Z',
|
|
102
|
+
lastFullFetchAt: '2026-06-26T11:00:00.000Z',
|
|
103
|
+
project: {},
|
|
104
|
+
issues: [],
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
92
107
|
|
|
93
108
|
await writeMachineStatus({
|
|
94
109
|
dashboardDataDir: dir,
|
|
@@ -97,7 +112,10 @@ describe('writeMachineStatus', () => {
|
|
|
97
112
|
});
|
|
98
113
|
|
|
99
114
|
const written = readJson(path.join(dir, 'machine-status.json'));
|
|
100
|
-
expect(written).toMatchObject({
|
|
115
|
+
expect(written).toMatchObject({
|
|
116
|
+
cycleMinutes: null,
|
|
117
|
+
lastFetchedAt: '2026-06-26T12:00:00.000Z',
|
|
118
|
+
});
|
|
101
119
|
});
|
|
102
120
|
|
|
103
121
|
it('is a no-op when dashboardDataDir is unset', async () => {
|
|
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import {
|
|
4
4
|
ProcHostMetricsRepository,
|
|
5
|
-
|
|
5
|
+
cycleMinutesFromFetchTimestamps,
|
|
6
6
|
} from '../../repositories/ProcHostMetricsRepository';
|
|
7
7
|
|
|
8
8
|
export type MachineStatusWriterParams = {
|
|
@@ -18,6 +18,7 @@ export type MachineStatusFile = {
|
|
|
18
18
|
diskPct: number;
|
|
19
19
|
load: [number, number, number];
|
|
20
20
|
cycleMinutes: number | null;
|
|
21
|
+
lastFetchedAt: string | null;
|
|
21
22
|
capturedAt: string;
|
|
22
23
|
};
|
|
23
24
|
|
|
@@ -29,24 +30,26 @@ const writeJsonAtomic = (filePath: string, data: unknown): void => {
|
|
|
29
30
|
fs.renameSync(tmpPath, filePath);
|
|
30
31
|
};
|
|
31
32
|
|
|
32
|
-
const
|
|
33
|
-
|
|
33
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
34
|
+
typeof value === 'object' && value !== null;
|
|
35
|
+
|
|
36
|
+
const readLastFetchedAtFromJsonFile = (filePath: string): string | null => {
|
|
37
|
+
let raw: string;
|
|
34
38
|
try {
|
|
35
|
-
|
|
39
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
36
40
|
} catch {
|
|
37
|
-
return
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
let parsed: unknown;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (isRecord(parsed) && typeof parsed.lastFetchedAt === 'string') {
|
|
50
|
+
return parsed.lastFetchedAt;
|
|
38
51
|
}
|
|
39
|
-
return
|
|
40
|
-
.filter((entry) => entry.endsWith('.json'))
|
|
41
|
-
.map((entry) => {
|
|
42
|
-
try {
|
|
43
|
-
return fs.statSync(path.join(allIssuesCacheDir, entry)).mtimeMs / 1000;
|
|
44
|
-
} catch {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
})
|
|
48
|
-
.filter((value): value is number => value !== null)
|
|
49
|
-
.sort((a, b) => b - a);
|
|
52
|
+
return null;
|
|
50
53
|
};
|
|
51
54
|
|
|
52
55
|
export const writeMachineStatus = async (
|
|
@@ -65,9 +68,16 @@ export const writeMachineStatus = async (
|
|
|
65
68
|
const diskPct = hostMetricsRepository.readDiskUsedPercent();
|
|
66
69
|
const load = hostMetricsRepository.readLoadAverages();
|
|
67
70
|
|
|
68
|
-
const
|
|
69
|
-
|
|
71
|
+
const machineStatusPath = path.join(dashboardDataDir, 'machine-status.json');
|
|
72
|
+
const previousLastFetchedAt =
|
|
73
|
+
readLastFetchedAtFromJsonFile(machineStatusPath);
|
|
74
|
+
const currentLastFetchedAt = allIssuesCacheDir
|
|
75
|
+
? readLastFetchedAtFromJsonFile(path.join(allIssuesCacheDir, 'latest.json'))
|
|
70
76
|
: null;
|
|
77
|
+
const cycleMinutes = cycleMinutesFromFetchTimestamps(
|
|
78
|
+
previousLastFetchedAt,
|
|
79
|
+
currentLastFetchedAt,
|
|
80
|
+
);
|
|
71
81
|
|
|
72
82
|
const file: MachineStatusFile = {
|
|
73
83
|
memPct,
|
|
@@ -75,8 +85,9 @@ export const writeMachineStatus = async (
|
|
|
75
85
|
diskPct,
|
|
76
86
|
load: [load.oneMinute, load.fiveMinute, load.fifteenMinute],
|
|
77
87
|
cycleMinutes,
|
|
88
|
+
lastFetchedAt: currentLastFetchedAt,
|
|
78
89
|
capturedAt: (params.now ?? new Date()).toISOString(),
|
|
79
90
|
};
|
|
80
91
|
|
|
81
|
-
writeJsonAtomic(
|
|
92
|
+
writeJsonAtomic(machineStatusPath, file);
|
|
82
93
|
};
|
|
@@ -56,7 +56,6 @@ const baseParams = {
|
|
|
56
56
|
config: {
|
|
57
57
|
maximumPreparingIssuesCount: 6,
|
|
58
58
|
utilizationPercentageThreshold: 90,
|
|
59
|
-
allowIssueCacheMinutes: 5,
|
|
60
59
|
thresholdForAutoReject: 3,
|
|
61
60
|
},
|
|
62
61
|
};
|
|
@@ -355,10 +354,6 @@ describe('writeSituationFile', () => {
|
|
|
355
354
|
expect.any(String),
|
|
356
355
|
expect.stringContaining('"utilizationPercentageThreshold":90'),
|
|
357
356
|
);
|
|
358
|
-
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
359
|
-
expect.any(String),
|
|
360
|
-
expect.stringContaining('"allowIssueCacheMinutes":5'),
|
|
361
|
-
);
|
|
362
357
|
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
363
358
|
expect.any(String),
|
|
364
359
|
expect.stringContaining('"thresholdForAutoReject":3'),
|
|
@@ -15,7 +15,6 @@ export type SituationFileParams = {
|
|
|
15
15
|
config: {
|
|
16
16
|
maximumPreparingIssuesCount: number | null;
|
|
17
17
|
utilizationPercentageThreshold: number;
|
|
18
|
-
allowIssueCacheMinutes: number;
|
|
19
18
|
thresholdForAutoReject: number;
|
|
20
19
|
};
|
|
21
20
|
preparationProcessCheckCommand?: string | null;
|
|
@@ -7,8 +7,6 @@ import { cleanStaleTmuxSessions } from './staleTmuxSessionCleaner';
|
|
|
7
7
|
|
|
8
8
|
const NOW = new Date('2026-06-26T00:00:00.000Z');
|
|
9
9
|
const NOW_EPOCH_SECONDS = Math.floor(NOW.getTime() / 1000);
|
|
10
|
-
const ALLOW_CACHE_MINUTES = 10;
|
|
11
|
-
|
|
12
10
|
const makeProject = (): Project => ({
|
|
13
11
|
id: 'project-1',
|
|
14
12
|
url: 'https://github.com/users/user/projects/1',
|
|
@@ -91,7 +89,6 @@ describe('cleanStaleTmuxSessions', () => {
|
|
|
91
89
|
|
|
92
90
|
await cleanStaleTmuxSessions({
|
|
93
91
|
project: makeProject(),
|
|
94
|
-
allowCacheMinutes: ALLOW_CACHE_MINUTES,
|
|
95
92
|
issueRepository: createMockIssueRepository([
|
|
96
93
|
makeIssue({ status: 'In Progress' }),
|
|
97
94
|
]),
|
|
@@ -124,7 +121,6 @@ describe('cleanStaleTmuxSessions', () => {
|
|
|
124
121
|
|
|
125
122
|
await cleanStaleTmuxSessions({
|
|
126
123
|
project: makeProject(),
|
|
127
|
-
allowCacheMinutes: ALLOW_CACHE_MINUTES,
|
|
128
124
|
issueRepository: createMockIssueRepository([
|
|
129
125
|
makeIssue({ status: IN_TMUX_STATUS_NAME }),
|
|
130
126
|
]),
|
|
@@ -155,7 +151,6 @@ describe('cleanStaleTmuxSessions', () => {
|
|
|
155
151
|
|
|
156
152
|
await cleanStaleTmuxSessions({
|
|
157
153
|
project: makeProject(),
|
|
158
|
-
allowCacheMinutes: ALLOW_CACHE_MINUTES,
|
|
159
154
|
issueRepository: createMockIssueRepository([]),
|
|
160
155
|
localCommandRunner: runner,
|
|
161
156
|
now: NOW,
|
|
@@ -10,7 +10,6 @@ import { NodeTmuxSessionRepository } from '../../repositories/NodeTmuxSessionRep
|
|
|
10
10
|
|
|
11
11
|
export type CleanStaleTmuxSessionsParams = {
|
|
12
12
|
project: Project;
|
|
13
|
-
allowCacheMinutes: number;
|
|
14
13
|
issueRepository: Pick<IssueRepository, 'getAllOpened'>;
|
|
15
14
|
localCommandRunner: LocalCommandRunner;
|
|
16
15
|
now: Date;
|
|
@@ -19,20 +18,13 @@ export type CleanStaleTmuxSessionsParams = {
|
|
|
19
18
|
export const cleanStaleTmuxSessions = async (
|
|
20
19
|
params: CleanStaleTmuxSessionsParams,
|
|
21
20
|
): Promise<void> => {
|
|
22
|
-
const {
|
|
23
|
-
project,
|
|
24
|
-
allowCacheMinutes,
|
|
25
|
-
issueRepository,
|
|
26
|
-
localCommandRunner,
|
|
27
|
-
now,
|
|
28
|
-
} = params;
|
|
21
|
+
const { project, issueRepository, localCommandRunner, now } = params;
|
|
29
22
|
const useCase = new StaleTmuxSessionKillUseCase(
|
|
30
23
|
issueRepository,
|
|
31
24
|
new NodeTmuxSessionRepository(localCommandRunner),
|
|
32
25
|
);
|
|
33
26
|
await useCase.run({
|
|
34
27
|
project,
|
|
35
|
-
allowCacheMinutes,
|
|
36
28
|
excludedStatus: DEFAULT_EXCLUDED_STATUS,
|
|
37
29
|
idleThresholdSeconds: DEFAULT_IDLE_THRESHOLD_SECONDS,
|
|
38
30
|
now,
|
|
@@ -177,4 +177,79 @@ describe('LocalStorageCacheRepository', () => {
|
|
|
177
177
|
expect(callOrder).toEqual(['write', 'rename']);
|
|
178
178
|
});
|
|
179
179
|
});
|
|
180
|
+
|
|
181
|
+
describe('getSingle', () => {
|
|
182
|
+
test('returns null when latest.json does not exist', async () => {
|
|
183
|
+
localStorageRepository.listFiles.mockReturnValue([]);
|
|
184
|
+
|
|
185
|
+
const result = await repository.getSingle('test-key');
|
|
186
|
+
|
|
187
|
+
expect(result).toBeNull();
|
|
188
|
+
expect(localStorageRepository.read).not.toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('returns null when the file content is empty', async () => {
|
|
192
|
+
localStorageRepository.listFiles.mockReturnValue(['latest.json']);
|
|
193
|
+
localStorageRepository.read.mockReturnValue(null);
|
|
194
|
+
|
|
195
|
+
const result = await repository.getSingle('test-key');
|
|
196
|
+
|
|
197
|
+
expect(result).toBeNull();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('returns null when the file content is invalid JSON', async () => {
|
|
201
|
+
localStorageRepository.listFiles.mockReturnValue(['latest.json']);
|
|
202
|
+
localStorageRepository.read.mockReturnValue('not-json');
|
|
203
|
+
|
|
204
|
+
const result = await repository.getSingle('test-key');
|
|
205
|
+
|
|
206
|
+
expect(result).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('returns the parsed value from latest.json', async () => {
|
|
210
|
+
localStorageRepository.listFiles.mockReturnValue(['latest.json']);
|
|
211
|
+
localStorageRepository.read.mockReturnValue('{"lastFetchedAt":"x"}');
|
|
212
|
+
|
|
213
|
+
const result = await repository.getSingle('test-key');
|
|
214
|
+
|
|
215
|
+
expect(result).toEqual({ lastFetchedAt: 'x' });
|
|
216
|
+
expect(localStorageRepository.read).toHaveBeenCalledWith(
|
|
217
|
+
'./tmp/cache/test-key/latest.json',
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('setSingle', () => {
|
|
223
|
+
test('atomically writes a temp file then renames it to latest.json', async () => {
|
|
224
|
+
await repository.setSingle('test-key', { test: 'value' });
|
|
225
|
+
|
|
226
|
+
expect(localStorageRepository.mkdir).toHaveBeenCalledWith(
|
|
227
|
+
'./tmp/cache/test-key',
|
|
228
|
+
);
|
|
229
|
+
const writeArgs = localStorageRepository.write.mock.calls[0];
|
|
230
|
+
const writtenPath = writeArgs[0];
|
|
231
|
+
const finalPath = './tmp/cache/test-key/latest.json';
|
|
232
|
+
expect(writtenPath.startsWith(`${finalPath}.`)).toBe(true);
|
|
233
|
+
expect(writtenPath.endsWith('.tmp')).toBe(true);
|
|
234
|
+
expect(writeArgs[1]).toBe('{"test":"value"}');
|
|
235
|
+
expect(localStorageRepository.rename).toHaveBeenCalledWith(
|
|
236
|
+
writtenPath,
|
|
237
|
+
finalPath,
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('writes to the temp file before renaming it into place', async () => {
|
|
242
|
+
const callOrder: string[] = [];
|
|
243
|
+
localStorageRepository.write.mockImplementation(() => {
|
|
244
|
+
callOrder.push('write');
|
|
245
|
+
});
|
|
246
|
+
localStorageRepository.rename.mockImplementation(() => {
|
|
247
|
+
callOrder.push('rename');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await repository.setSingle('ordering-key', { ordered: true });
|
|
251
|
+
|
|
252
|
+
expect(callOrder).toEqual(['write', 'rename']);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
180
255
|
});
|
|
@@ -51,4 +51,28 @@ export class LocalStorageCacheRepository {
|
|
|
51
51
|
this.localStorageRepository.write(tmpPath, JSON.stringify(value));
|
|
52
52
|
this.localStorageRepository.rename(tmpPath, finalPath);
|
|
53
53
|
};
|
|
54
|
+
getSingle = async (key: string): Promise<unknown> => {
|
|
55
|
+
const dirPath = `${this.cachePath}/${key}`;
|
|
56
|
+
const fileName = 'latest.json';
|
|
57
|
+
if (!this.localStorageRepository.listFiles(dirPath).includes(fileName)) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const valueStr = this.localStorageRepository.read(`${dirPath}/${fileName}`);
|
|
61
|
+
if (!valueStr) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(valueStr);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
setSingle = async <T>(key: string, value: T): Promise<void> => {
|
|
71
|
+
const dirPath = `${this.cachePath}/${key}`;
|
|
72
|
+
this.localStorageRepository.mkdir(dirPath);
|
|
73
|
+
const finalPath = `${dirPath}/latest.json`;
|
|
74
|
+
const tmpPath = `${finalPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
75
|
+
this.localStorageRepository.write(tmpPath, JSON.stringify(value));
|
|
76
|
+
this.localStorageRepository.rename(tmpPath, finalPath);
|
|
77
|
+
};
|
|
54
78
|
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
DiskBlocks,
|
|
6
6
|
ProcHostMetricsRepository,
|
|
7
7
|
cpuUsedPercentFromSamples,
|
|
8
|
-
|
|
8
|
+
cycleMinutesFromFetchTimestamps,
|
|
9
9
|
parseCpuSample,
|
|
10
10
|
parseDiskUsedPercent,
|
|
11
11
|
parseLoadAverages,
|
|
@@ -88,18 +88,39 @@ describe('parseDiskUsedPercent', () => {
|
|
|
88
88
|
});
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
describe('
|
|
92
|
-
it('rounds the gap between the
|
|
93
|
-
expect(
|
|
91
|
+
describe('cycleMinutesFromFetchTimestamps', () => {
|
|
92
|
+
it('rounds the gap between the previous and current fetch to whole minutes', () => {
|
|
93
|
+
expect(
|
|
94
|
+
cycleMinutesFromFetchTimestamps(
|
|
95
|
+
'2026-07-07T00:00:00.000Z',
|
|
96
|
+
'2026-07-07T00:14:00.000Z',
|
|
97
|
+
),
|
|
98
|
+
).toBe(14);
|
|
94
99
|
});
|
|
95
100
|
|
|
96
101
|
it('rounds a 1620 second gap to 27 minutes', () => {
|
|
97
|
-
expect(
|
|
102
|
+
expect(
|
|
103
|
+
cycleMinutesFromFetchTimestamps(
|
|
104
|
+
'2026-07-07T00:00:00.000Z',
|
|
105
|
+
'2026-07-07T00:27:00.000Z',
|
|
106
|
+
),
|
|
107
|
+
).toBe(27);
|
|
98
108
|
});
|
|
99
109
|
|
|
100
|
-
it('returns null when
|
|
101
|
-
expect(
|
|
102
|
-
|
|
110
|
+
it('returns null when either timestamp is missing', () => {
|
|
111
|
+
expect(
|
|
112
|
+
cycleMinutesFromFetchTimestamps(null, '2026-07-07T00:00:00.000Z'),
|
|
113
|
+
).toBeNull();
|
|
114
|
+
expect(
|
|
115
|
+
cycleMinutesFromFetchTimestamps('2026-07-07T00:00:00.000Z', null),
|
|
116
|
+
).toBeNull();
|
|
117
|
+
expect(cycleMinutesFromFetchTimestamps(null, null)).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('returns null when a timestamp is not parseable', () => {
|
|
121
|
+
expect(
|
|
122
|
+
cycleMinutesFromFetchTimestamps('not-a-date', '2026-07-07T00:00:00.000Z'),
|
|
123
|
+
).toBeNull();
|
|
103
124
|
});
|
|
104
125
|
});
|
|
105
126
|
|
|
@@ -111,15 +111,19 @@ export const parseLoadAverages = (loadavgText: string): LoadAverages => {
|
|
|
111
111
|
return { oneMinute, fiveMinute, fifteenMinute };
|
|
112
112
|
};
|
|
113
113
|
|
|
114
|
-
export const
|
|
115
|
-
|
|
114
|
+
export const cycleMinutesFromFetchTimestamps = (
|
|
115
|
+
previousFetchedAtIso: string | null,
|
|
116
|
+
currentFetchedAtIso: string | null,
|
|
116
117
|
): number | null => {
|
|
117
|
-
if (
|
|
118
|
+
if (previousFetchedAtIso === null || currentFetchedAtIso === null) {
|
|
118
119
|
return null;
|
|
119
120
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
121
|
+
const previousMs = new Date(previousFetchedAtIso).getTime();
|
|
122
|
+
const currentMs = new Date(currentFetchedAtIso).getTime();
|
|
123
|
+
if (!Number.isFinite(previousMs) || !Number.isFinite(currentMs)) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return Math.round((currentMs - previousMs) / 60000);
|
|
123
127
|
};
|
|
124
128
|
|
|
125
129
|
export class ProcHostMetricsRepository {
|