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.
Files changed (112) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +6 -8
  3. package/bin/adapter/entry-points/cli/index.js +8 -14
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/cli/projectConfig.js +0 -6
  6. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js +6 -12
  8. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -10
  10. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  11. package/bin/adapter/entry-points/handlers/machineStatusWriter.js +23 -19
  12. package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -1
  13. package/bin/adapter/entry-points/handlers/situationFileWriter.js.map +1 -1
  14. package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js +1 -2
  15. package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js.map +1 -1
  16. package/bin/adapter/repositories/LocalStorageCacheRepository.js +25 -0
  17. package/bin/adapter/repositories/LocalStorageCacheRepository.js.map +1 -1
  18. package/bin/adapter/repositories/ProcHostMetricsRepository.js +10 -5
  19. package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -1
  20. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +119 -59
  21. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  22. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +12 -3
  23. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  24. package/bin/domain/usecases/GetStoryObjectMapUseCase.js +1 -1
  25. package/bin/domain/usecases/GetStoryObjectMapUseCase.js.map +1 -1
  26. package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -8
  27. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  28. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +2 -2
  29. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  30. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js +1 -1
  31. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js.map +1 -1
  32. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +1 -1
  33. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  34. package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js +1 -1
  35. package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js.map +1 -1
  36. package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js +1 -1
  37. package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js.map +1 -1
  38. package/bin/domain/usecases/StartPreparationUseCase.js +1 -1
  39. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/adapter/entry-points/cli/index.test.ts +0 -5
  42. package/src/adapter/entry-points/cli/index.ts +16 -17
  43. package/src/adapter/entry-points/cli/projectConfig.ts +0 -8
  44. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.test.ts +4 -25
  45. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.ts +4 -7
  46. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +2 -25
  47. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +2 -4
  48. package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +29 -11
  49. package/src/adapter/entry-points/handlers/machineStatusWriter.ts +30 -19
  50. package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +0 -5
  51. package/src/adapter/entry-points/handlers/situationFileWriter.ts +0 -1
  52. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +0 -5
  53. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.ts +1 -9
  54. package/src/adapter/repositories/LocalStorageCacheRepository.test.ts +75 -0
  55. package/src/adapter/repositories/LocalStorageCacheRepository.ts +24 -0
  56. package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +29 -8
  57. package/src/adapter/repositories/ProcHostMetricsRepository.ts +10 -6
  58. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +198 -47
  59. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +160 -81
  60. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +23 -3
  61. package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +7 -12
  62. package/src/domain/usecases/GetStoryObjectMapUseCase.ts +1 -5
  63. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +8 -24
  64. package/src/domain/usecases/HandleScheduledEventUseCase.ts +6 -17
  65. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +4 -8
  66. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +35 -42
  67. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.ts +1 -5
  68. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +32 -32
  69. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +1 -5
  70. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +17 -4
  71. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.ts +1 -4
  72. package/src/domain/usecases/StaleTmuxSessionKillUseCase.test.ts +1 -7
  73. package/src/domain/usecases/StaleTmuxSessionKillUseCase.ts +1 -5
  74. package/src/domain/usecases/StartPreparationUseCase.test.ts +1 -94
  75. package/src/domain/usecases/StartPreparationUseCase.ts +2 -5
  76. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +3 -10
  77. package/types/adapter/entry-points/cli/projectConfig.d.ts +0 -1
  78. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  79. package/types/adapter/entry-points/function/getStoryObjectMap.d.ts +1 -1
  80. package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts +1 -1
  81. package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts.map +1 -1
  82. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  83. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +1 -0
  84. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -1
  85. package/types/adapter/entry-points/handlers/situationFileWriter.d.ts +0 -1
  86. package/types/adapter/entry-points/handlers/situationFileWriter.d.ts.map +1 -1
  87. package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts +0 -1
  88. package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts.map +1 -1
  89. package/types/adapter/repositories/LocalStorageCacheRepository.d.ts +2 -0
  90. package/types/adapter/repositories/LocalStorageCacheRepository.d.ts.map +1 -1
  91. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +1 -1
  92. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -1
  93. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +22 -7
  94. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  95. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +2 -1
  96. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
  97. package/types/domain/usecases/GetStoryObjectMapUseCase.d.ts +0 -1
  98. package/types/domain/usecases/GetStoryObjectMapUseCase.d.ts.map +1 -1
  99. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +0 -1
  100. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  101. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  102. package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts +0 -1
  103. package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts.map +1 -1
  104. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +0 -1
  105. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  106. package/types/domain/usecases/SetupTowerDefenceProjectUseCase.d.ts.map +1 -1
  107. package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts +0 -1
  108. package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts.map +1 -1
  109. package/types/domain/usecases/StartPreparationUseCase.d.ts +0 -1
  110. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  111. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +4 -3
  112. 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
- const newer = path.join(cacheDir, 'a.json');
61
- const older = path.join(cacheDir, 'b.json');
62
- fs.writeFileSync(older, '[]');
63
- fs.writeFileSync(newer, '[]');
64
- const base = Date.now();
65
- fs.utimesSync(older, new Date(base - 900000), new Date(base - 900000));
66
- fs.utimesSync(newer, new Date(base), new Date(base));
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 fewer than two cache files exist', async () => {
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(path.join(cacheDir, 'only.json'), '[]');
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({ cycleMinutes: null });
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
- cycleMinutesFromMtimes,
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 cacheFileMtimesDescending = (allIssuesCacheDir: string): number[] => {
33
- let entries: string[];
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
- entries = fs.readdirSync(allIssuesCacheDir);
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 entries
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 cycleMinutes = allIssuesCacheDir
69
- ? cycleMinutesFromMtimes(cacheFileMtimesDescending(allIssuesCacheDir))
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(path.join(dashboardDataDir, 'machine-status.json'), file);
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
- cycleMinutesFromMtimes,
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('cycleMinutesFromMtimes', () => {
92
- it('rounds the gap between the two newest generations to whole minutes', () => {
93
- expect(cycleMinutesFromMtimes([1782469254.0, 1782468443.0])).toBe(14);
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(cycleMinutesFromMtimes([1620.0, 0.0])).toBe(27);
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 a second generation is missing', () => {
101
- expect(cycleMinutesFromMtimes([1000.0])).toBeNull();
102
- expect(cycleMinutesFromMtimes([])).toBeNull();
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 cycleMinutesFromMtimes = (
115
- mtimesDescendingSeconds: number[],
114
+ export const cycleMinutesFromFetchTimestamps = (
115
+ previousFetchedAtIso: string | null,
116
+ currentFetchedAtIso: string | null,
116
117
  ): number | null => {
117
- if (mtimesDescendingSeconds.length < 2) {
118
+ if (previousFetchedAtIso === null || currentFetchedAtIso === null) {
118
119
  return null;
119
120
  }
120
- return Math.round(
121
- (mtimesDescendingSeconds[0] - mtimesDescendingSeconds[1]) / 60,
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 {