github-issue-tower-defence-management 1.117.8 → 1.117.9

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 (30) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +4 -2
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +14 -2
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +7 -2
  6. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  7. package/bin/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.js +128 -0
  8. package/bin/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.js.map +1 -0
  9. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +16 -4
  10. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  11. package/bin/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.js +3 -0
  12. package/bin/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.js.map +1 -0
  13. package/package.json +1 -1
  14. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +11 -0
  15. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +37 -3
  16. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +14 -0
  17. package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.test.ts +201 -0
  18. package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.ts +115 -0
  19. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +117 -22
  20. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +22 -2
  21. package/src/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.ts +10 -0
  22. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  23. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -0
  24. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  25. package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts +18 -0
  26. package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts.map +1 -0
  27. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +5 -1
  28. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  29. package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts +11 -0
  30. package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts.map +1 -0
@@ -36,10 +36,15 @@ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
36
36
 
37
37
  describe('notifySilentTmuxSessions', () => {
38
38
  let configDir: string;
39
+ let candidateStateFilePath: string;
39
40
 
40
41
  beforeEach(() => {
41
42
  jest.spyOn(console, 'log').mockImplementation(() => undefined);
42
43
  configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'silent-config-'));
44
+ candidateStateFilePath = path.join(
45
+ configDir,
46
+ 'silent-session-candidates.json',
47
+ );
43
48
  });
44
49
 
45
50
  afterEach(() => {
@@ -47,6 +52,18 @@ describe('notifySilentTmuxSessions', () => {
47
52
  fs.rmSync(configDir, { force: true, recursive: true });
48
53
  });
49
54
 
55
+ const seedPreviousCandidates = (sessionNames: string[]): void => {
56
+ fs.writeFileSync(
57
+ candidateStateFilePath,
58
+ JSON.stringify({
59
+ candidates: sessionNames.map((sessionName) => ({
60
+ sessionName,
61
+ recordedEpochSeconds: NOW_EPOCH_SECONDS,
62
+ })),
63
+ }),
64
+ );
65
+ };
66
+
50
67
  const writeTranscript = (lines: object[]): void => {
51
68
  const projectDirectory = path.join(configDir, 'projects', '-home-user');
52
69
  fs.mkdirSync(projectDirectory, { recursive: true });
@@ -114,6 +131,7 @@ describe('notifySilentTmuxSessions', () => {
114
131
  subAgentOutputRootDirectory: null,
115
132
  subAgentProcessMatchPattern: null,
116
133
  subAgentTranscriptRootDirectory: null,
134
+ candidateDebounceStateFilePath: candidateStateFilePath,
117
135
  activeHubTaskStatus: null,
118
136
  hubTaskStatusResolver: null,
119
137
  messageTemplates: EMPTY_TEMPLATES,
@@ -121,8 +139,9 @@ describe('notifySilentTmuxSessions', () => {
121
139
  ...DEFAULT_NOTIFY_SILENT_TMUX_SESSIONS_PARAMS,
122
140
  });
123
141
 
124
- it('sends a main stalled notification to a silent github-named live session', async () => {
142
+ it('sends a main stalled notification to a silent github-named live session that was already a candidate in the previous cycle', async () => {
125
143
  silentAssistantTranscript();
144
+ seedPreviousCandidates([SESSION_NAME]);
126
145
  const runner = liveSessionRunner();
127
146
 
128
147
  await notifySilentTmuxSessions(baseParams(runner));
@@ -134,6 +153,18 @@ describe('notifySilentTmuxSessions', () => {
134
153
  expect(sendCall?.[1][4]).toContain('You have produced no output for');
135
154
  });
136
155
 
156
+ it('does not notify a silent github-named live session on its first candidate cycle', async () => {
157
+ silentAssistantTranscript();
158
+ const runner = liveSessionRunner();
159
+
160
+ await notifySilentTmuxSessions(baseParams(runner));
161
+
162
+ const sendCall = runner.runCommand.mock.calls.find(
163
+ (call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
164
+ );
165
+ expect(sendCall).toBeUndefined();
166
+ });
167
+
137
168
  it('sends no notification to a silent non-github-named live session', async () => {
138
169
  silentAssistantTranscript();
139
170
  const runner = createMockRunner();
@@ -176,6 +207,7 @@ describe('notifySilentTmuxSessions', () => {
176
207
 
177
208
  it('uses the configured main stalled message template when provided', async () => {
178
209
  silentAssistantTranscript();
210
+ seedPreviousCandidates([SESSION_NAME]);
179
211
  const runner = liveSessionRunner();
180
212
 
181
213
  await notifySilentTmuxSessions({
@@ -230,7 +262,7 @@ describe('notifySilentTmuxSessions', () => {
230
262
  expect(sendCall).toBeUndefined();
231
263
  });
232
264
 
233
- it('re-notifies the same silent session on the next cycle with no cooldown suppression', async () => {
265
+ it('defers the first cycle then notifies on the next cycle once the persisted candidate state confirms a second consecutive cycle', async () => {
234
266
  silentAssistantTranscript();
235
267
  const firstRunner = liveSessionRunner();
236
268
 
@@ -239,7 +271,7 @@ describe('notifySilentTmuxSessions', () => {
239
271
  const firstSendCall = firstRunner.runCommand.mock.calls.find(
240
272
  (call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
241
273
  );
242
- expect(firstSendCall?.[1][2]).toBe(SESSION_NAME);
274
+ expect(firstSendCall).toBeUndefined();
243
275
 
244
276
  const secondRunner = liveSessionRunner();
245
277
  await notifySilentTmuxSessions({
@@ -333,6 +365,7 @@ describe('notifySilentTmuxSessions', () => {
333
365
 
334
366
  it('skips a URL-named session whose hub task is no longer in the active status', async () => {
335
367
  writeUrlSessionTranscript();
368
+ seedPreviousCandidates([HUB_TASK_SESSION_NAME]);
336
369
  const runner = urlSessionRunner();
337
370
  const getIssueByUrl = jest
338
371
  .fn()
@@ -353,6 +386,7 @@ describe('notifySilentTmuxSessions', () => {
353
386
 
354
387
  it('sends to a URL-named session whose hub task is in the active status', async () => {
355
388
  writeUrlSessionTranscript();
389
+ seedPreviousCandidates([HUB_TASK_SESSION_NAME]);
356
390
  const runner = urlSessionRunner();
357
391
  const getIssueByUrl = jest
358
392
  .fn()
@@ -9,6 +9,7 @@ import {
9
9
  DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
10
10
  DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
11
11
  DEFAULT_NOTIFICATION_STAGGER_SECONDS,
12
+ DEFAULT_CANDIDATE_DEBOUNCE_RECENCY_WINDOW_SECONDS,
12
13
  } from '../../../domain/usecases/NotifySilentLiveSessionsUseCase';
13
14
  import { DefaultSilentSessionMessageComposer } from '../../../domain/usecases/DefaultSilentSessionMessageComposer';
14
15
  import { LocalProcessLiveSessionProcessSnapshotProvider } from '../../repositories/LocalProcessLiveSessionProcessSnapshotProvider';
@@ -28,6 +29,7 @@ import {
28
29
  SilentSessionMessageTemplates,
29
30
  } from '../../repositories/ConfigurableSilentSessionMessageComposer';
30
31
  import { RealSleeper } from '../../repositories/RealSleeper';
32
+ import { FileSystemSilentSessionCandidateStateRepository } from '../../repositories/FileSystemSilentSessionCandidateStateRepository';
31
33
 
32
34
  export type NotifySilentTmuxSessionsParams = {
33
35
  enabled: boolean;
@@ -41,6 +43,8 @@ export type NotifySilentTmuxSessionsParams = {
41
43
  subAgentSilentThresholdSeconds: number;
42
44
  subAgentRunningThresholdSeconds: number;
43
45
  staggerSeconds: number;
46
+ candidateDebounceRecencyWindowSeconds: number;
47
+ candidateDebounceStateFilePath: string | null;
44
48
  activeHubTaskStatus: string | null;
45
49
  hubTaskStatusResolver: HubTaskStatusResolver | null;
46
50
  messageTemplates: SilentSessionMessageTemplates;
@@ -96,6 +100,8 @@ export const notifySilentTmuxSessions = async (
96
100
  subAgentSilentThresholdSeconds,
97
101
  subAgentRunningThresholdSeconds,
98
102
  staggerSeconds,
103
+ candidateDebounceRecencyWindowSeconds,
104
+ candidateDebounceStateFilePath,
99
105
  activeHubTaskStatus,
100
106
  hubTaskStatusResolver,
101
107
  messageTemplates,
@@ -127,6 +133,11 @@ export const notifySilentTmuxSessions = async (
127
133
  ),
128
134
  createOwnerCallStatusProvider(ownerCallMarker),
129
135
  new TmuxSilentSessionNotificationRepository(localCommandRunner),
136
+ candidateDebounceStateFilePath !== null
137
+ ? new FileSystemSilentSessionCandidateStateRepository(
138
+ candidateDebounceStateFilePath,
139
+ )
140
+ : new FileSystemSilentSessionCandidateStateRepository(),
130
141
  messageComposer,
131
142
  new RealSleeper(),
132
143
  hubTaskStatusResolver,
@@ -136,6 +147,7 @@ export const notifySilentTmuxSessions = async (
136
147
  subAgentSilentThresholdSeconds,
137
148
  subAgentRunningThresholdSeconds,
138
149
  staggerSeconds,
150
+ candidateDebounceRecencyWindowSeconds,
139
151
  activeHubTaskStatus,
140
152
  now,
141
153
  });
@@ -146,4 +158,6 @@ export const DEFAULT_NOTIFY_SILENT_TMUX_SESSIONS_PARAMS = {
146
158
  subAgentSilentThresholdSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
147
159
  subAgentRunningThresholdSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
148
160
  staggerSeconds: DEFAULT_NOTIFICATION_STAGGER_SECONDS,
161
+ candidateDebounceRecencyWindowSeconds:
162
+ DEFAULT_CANDIDATE_DEBOUNCE_RECENCY_WINDOW_SECONDS,
149
163
  } as const;
@@ -0,0 +1,201 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import {
5
+ DEFAULT_STATE_RETENTION_WINDOW_SECONDS,
6
+ FileSystemSilentSessionCandidateStateRepository,
7
+ } from './FileSystemSilentSessionCandidateStateRepository';
8
+
9
+ describe('FileSystemSilentSessionCandidateStateRepository', () => {
10
+ let rootDirectory: string;
11
+ let stateFilePath: string;
12
+
13
+ beforeEach(() => {
14
+ rootDirectory = fs.mkdtempSync(
15
+ path.join(os.tmpdir(), 'silent-session-candidate-state-'),
16
+ );
17
+ stateFilePath = path.join(rootDirectory, 'silent-session-candidates.json');
18
+ });
19
+
20
+ afterEach(() => {
21
+ fs.rmSync(rootDirectory, { force: true, recursive: true });
22
+ });
23
+
24
+ const sessionAlpha = 'https://github.com/HiromiShikata/repo/issues/100';
25
+ const sessionBravo = 'https://github.com/HiromiShikata/repo/issues/200';
26
+
27
+ it('round-trips a saved candidate set so the next cycle reads it back', async () => {
28
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
29
+ stateFilePath,
30
+ );
31
+ const now = new Date('2026-06-26T00:00:00Z');
32
+
33
+ await repository.saveCandidateSessionNames({
34
+ sessionNames: [sessionAlpha, sessionBravo],
35
+ now,
36
+ });
37
+ const loaded = await repository.loadRecentCandidateSessionNames({
38
+ now: new Date('2026-06-26T00:01:00Z'),
39
+ recencyWindowSeconds: 15 * 60,
40
+ });
41
+
42
+ expect(loaded).toEqual(new Set([sessionAlpha, sessionBravo]));
43
+ });
44
+
45
+ it('returns an empty set when no state file exists yet', async () => {
46
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
47
+ stateFilePath,
48
+ );
49
+
50
+ const loaded = await repository.loadRecentCandidateSessionNames({
51
+ now: new Date('2026-06-26T00:00:00Z'),
52
+ recencyWindowSeconds: 15 * 60,
53
+ });
54
+
55
+ expect(loaded).toEqual(new Set<string>());
56
+ });
57
+
58
+ it('excludes an entry older than the recency window when loading', async () => {
59
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
60
+ stateFilePath,
61
+ );
62
+ const savedAt = new Date('2026-06-26T00:00:00Z');
63
+
64
+ await repository.saveCandidateSessionNames({
65
+ sessionNames: [sessionAlpha],
66
+ now: savedAt,
67
+ });
68
+ const loaded = await repository.loadRecentCandidateSessionNames({
69
+ now: new Date('2026-06-26T00:16:00Z'),
70
+ recencyWindowSeconds: 15 * 60,
71
+ });
72
+
73
+ expect(loaded).toEqual(new Set<string>());
74
+ });
75
+
76
+ it('includes an entry recorded exactly at the recency-window boundary', async () => {
77
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
78
+ stateFilePath,
79
+ );
80
+ const savedAt = new Date('2026-06-26T00:00:00Z');
81
+
82
+ await repository.saveCandidateSessionNames({
83
+ sessionNames: [sessionAlpha],
84
+ now: savedAt,
85
+ });
86
+ const loaded = await repository.loadRecentCandidateSessionNames({
87
+ now: new Date('2026-06-26T00:15:00Z'),
88
+ recencyWindowSeconds: 15 * 60,
89
+ });
90
+
91
+ expect(loaded).toEqual(new Set([sessionAlpha]));
92
+ });
93
+
94
+ it('preserves another recent session recorded by a concurrent run when saving a disjoint set', async () => {
95
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
96
+ stateFilePath,
97
+ );
98
+ const firstSaveAt = new Date('2026-06-26T00:00:00Z');
99
+ const secondSaveAt = new Date('2026-06-26T00:00:30Z');
100
+
101
+ await repository.saveCandidateSessionNames({
102
+ sessionNames: [sessionAlpha],
103
+ now: firstSaveAt,
104
+ });
105
+ await repository.saveCandidateSessionNames({
106
+ sessionNames: [sessionBravo],
107
+ now: secondSaveAt,
108
+ });
109
+ const loaded = await repository.loadRecentCandidateSessionNames({
110
+ now: new Date('2026-06-26T00:01:00Z'),
111
+ recencyWindowSeconds: 15 * 60,
112
+ });
113
+
114
+ expect(loaded).toEqual(new Set([sessionAlpha, sessionBravo]));
115
+ });
116
+
117
+ it('drops a stored entry that has aged beyond the retention window on the next save', async () => {
118
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
119
+ stateFilePath,
120
+ 60 * 60,
121
+ );
122
+ const firstSaveAt = new Date('2026-06-26T00:00:00Z');
123
+ const secondSaveAt = new Date('2026-06-26T01:30:00Z');
124
+
125
+ await repository.saveCandidateSessionNames({
126
+ sessionNames: [sessionAlpha],
127
+ now: firstSaveAt,
128
+ });
129
+ await repository.saveCandidateSessionNames({
130
+ sessionNames: [sessionBravo],
131
+ now: secondSaveAt,
132
+ });
133
+
134
+ const persisted: unknown = JSON.parse(
135
+ fs.readFileSync(stateFilePath, 'utf8'),
136
+ );
137
+ expect(persisted).toEqual({
138
+ candidates: [
139
+ {
140
+ sessionName: sessionBravo,
141
+ recordedEpochSeconds: Math.floor(secondSaveAt.getTime() / 1000),
142
+ },
143
+ ],
144
+ });
145
+ });
146
+
147
+ it('refreshes the timestamp of a session that is a candidate again', async () => {
148
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
149
+ stateFilePath,
150
+ );
151
+ const firstSaveAt = new Date('2026-06-26T00:00:00Z');
152
+ const secondSaveAt = new Date('2026-06-26T00:14:00Z');
153
+
154
+ await repository.saveCandidateSessionNames({
155
+ sessionNames: [sessionAlpha],
156
+ now: firstSaveAt,
157
+ });
158
+ await repository.saveCandidateSessionNames({
159
+ sessionNames: [sessionAlpha],
160
+ now: secondSaveAt,
161
+ });
162
+ const loaded = await repository.loadRecentCandidateSessionNames({
163
+ now: new Date('2026-06-26T00:20:00Z'),
164
+ recencyWindowSeconds: 15 * 60,
165
+ });
166
+
167
+ expect(loaded).toEqual(new Set([sessionAlpha]));
168
+ });
169
+
170
+ it('treats a corrupt state file as no previous candidates', async () => {
171
+ fs.writeFileSync(stateFilePath, 'not valid json');
172
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
173
+ stateFilePath,
174
+ );
175
+
176
+ const loaded = await repository.loadRecentCandidateSessionNames({
177
+ now: new Date('2026-06-26T00:00:00Z'),
178
+ recencyWindowSeconds: 15 * 60,
179
+ });
180
+
181
+ expect(loaded).toEqual(new Set<string>());
182
+ });
183
+
184
+ it('creates the parent directory when the configured path does not exist yet', async () => {
185
+ const nestedPath = path.join(rootDirectory, 'nested', 'dir', 'state.json');
186
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
187
+ nestedPath,
188
+ );
189
+
190
+ await repository.saveCandidateSessionNames({
191
+ sessionNames: [sessionAlpha],
192
+ now: new Date('2026-06-26T00:00:00Z'),
193
+ });
194
+
195
+ expect(fs.existsSync(nestedPath)).toBe(true);
196
+ });
197
+
198
+ it('exposes the default retention window as a named constant', () => {
199
+ expect(DEFAULT_STATE_RETENTION_WINDOW_SECONDS).toBe(60 * 60);
200
+ });
201
+ });
@@ -0,0 +1,115 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { SilentSessionCandidateStateRepository } from '../../domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository';
5
+
6
+ type StoredCandidateEntry = {
7
+ sessionName: string;
8
+ recordedEpochSeconds: number;
9
+ };
10
+
11
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
12
+ typeof value === 'object' && value !== null && !Array.isArray(value);
13
+
14
+ export const DEFAULT_STATE_RETENTION_WINDOW_SECONDS = 60 * 60;
15
+
16
+ const defaultStateFilePath = (): string => {
17
+ const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
18
+ return path.join(base, 'tdpm', 'silent-session-candidates.json');
19
+ };
20
+
21
+ export class FileSystemSilentSessionCandidateStateRepository implements SilentSessionCandidateStateRepository {
22
+ constructor(
23
+ private readonly stateFilePath: string = defaultStateFilePath(),
24
+ private readonly retentionWindowSeconds: number = DEFAULT_STATE_RETENTION_WINDOW_SECONDS,
25
+ ) {}
26
+
27
+ loadRecentCandidateSessionNames = async (params: {
28
+ now: Date;
29
+ recencyWindowSeconds: number;
30
+ }): Promise<Set<string>> => {
31
+ const nowEpochSeconds = Math.floor(params.now.getTime() / 1000);
32
+ const oldestAllowedEpochSeconds =
33
+ nowEpochSeconds - params.recencyWindowSeconds;
34
+ const entries = this.readEntries();
35
+ const recentSessionNames = new Set<string>();
36
+ for (const entry of entries) {
37
+ if (entry.recordedEpochSeconds >= oldestAllowedEpochSeconds) {
38
+ recentSessionNames.add(entry.sessionName);
39
+ }
40
+ }
41
+ return recentSessionNames;
42
+ };
43
+
44
+ saveCandidateSessionNames = async (params: {
45
+ sessionNames: string[];
46
+ now: Date;
47
+ }): Promise<void> => {
48
+ const recordedEpochSeconds = Math.floor(params.now.getTime() / 1000);
49
+ const oldestRetainedEpochSeconds =
50
+ recordedEpochSeconds - this.retentionWindowSeconds;
51
+ const currentSessionNames = new Set(params.sessionNames);
52
+ const mergedBySessionName = new Map<string, StoredCandidateEntry>();
53
+ for (const entry of this.readEntries()) {
54
+ if (
55
+ entry.recordedEpochSeconds >= oldestRetainedEpochSeconds &&
56
+ !currentSessionNames.has(entry.sessionName)
57
+ ) {
58
+ mergedBySessionName.set(entry.sessionName, entry);
59
+ }
60
+ }
61
+ for (const sessionName of currentSessionNames) {
62
+ mergedBySessionName.set(sessionName, {
63
+ sessionName,
64
+ recordedEpochSeconds,
65
+ });
66
+ }
67
+ this.writeEntries(Array.from(mergedBySessionName.values()));
68
+ };
69
+
70
+ private readEntries = (): StoredCandidateEntry[] => {
71
+ let raw: string;
72
+ try {
73
+ raw = fs.readFileSync(this.stateFilePath, 'utf8');
74
+ } catch {
75
+ return [];
76
+ }
77
+ let parsed: unknown;
78
+ try {
79
+ parsed = JSON.parse(raw);
80
+ } catch {
81
+ return [];
82
+ }
83
+ if (!isRecord(parsed)) {
84
+ return [];
85
+ }
86
+ const storedEntries = parsed.candidates;
87
+ if (!Array.isArray(storedEntries)) {
88
+ return [];
89
+ }
90
+ const entries: StoredCandidateEntry[] = [];
91
+ for (const storedEntry of storedEntries) {
92
+ if (!isRecord(storedEntry)) {
93
+ continue;
94
+ }
95
+ const sessionName = storedEntry.sessionName;
96
+ const recordedEpochSeconds = storedEntry.recordedEpochSeconds;
97
+ if (
98
+ typeof sessionName === 'string' &&
99
+ typeof recordedEpochSeconds === 'number' &&
100
+ Number.isFinite(recordedEpochSeconds)
101
+ ) {
102
+ entries.push({ sessionName, recordedEpochSeconds });
103
+ }
104
+ }
105
+ return entries;
106
+ };
107
+
108
+ private writeEntries = (entries: StoredCandidateEntry[]): void => {
109
+ const directory = path.dirname(this.stateFilePath);
110
+ fs.mkdirSync(directory, { recursive: true });
111
+ const temporaryPath = `${this.stateFilePath}.${process.pid}.tmp`;
112
+ fs.writeFileSync(temporaryPath, JSON.stringify({ candidates: entries }));
113
+ fs.renameSync(temporaryPath, this.stateFilePath);
114
+ };
115
+ }