github-issue-tower-defence-management 1.118.0 → 1.119.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 (52) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -3
  3. package/bin/adapter/entry-points/console/ui-dist/assets/{index-BMe1bEF4.css → index-D0RwhyOM.css} +1 -1
  4. package/bin/adapter/entry-points/console/ui-dist/assets/index-syKbd_lW.js +103 -0
  5. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +14 -2
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  8. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +7 -2
  9. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  10. package/bin/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.js +128 -0
  11. package/bin/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.js.map +1 -0
  12. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +61 -16
  13. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  14. package/bin/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.js +3 -0
  15. package/bin/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.js.map +1 -0
  16. package/package.json +1 -1
  17. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +1 -1
  18. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.tsx +8 -2
  19. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.test.tsx +21 -8
  20. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.tsx +63 -6
  21. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentList.tsx +4 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +4 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.test.ts +37 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.ts +69 -5
  25. package/src/adapter/entry-points/console/ui/src/features/console/logic/fileTree.test.ts +95 -0
  26. package/src/adapter/entry-points/console/ui/src/features/console/logic/fileTree.ts +93 -0
  27. package/src/adapter/entry-points/console/ui/src/features/console/logic/references.test.ts +23 -0
  28. package/src/adapter/entry-points/console/ui/src/features/console/logic/references.ts +20 -0
  29. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +6 -2
  30. package/src/adapter/entry-points/console/ui/src/index.css +25 -0
  31. package/src/adapter/entry-points/console/ui-dist/assets/{index-BMe1bEF4.css → index-D0RwhyOM.css} +1 -1
  32. package/src/adapter/entry-points/console/ui-dist/assets/index-syKbd_lW.js +103 -0
  33. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  34. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +11 -0
  35. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +6 -0
  36. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +13 -0
  37. package/src/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.test.ts +251 -0
  38. package/src/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.ts +121 -0
  39. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +218 -9
  40. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +105 -19
  41. package/src/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.ts +20 -0
  42. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  43. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -0
  44. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  45. package/types/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.d.ts +20 -0
  46. package/types/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.d.ts.map +1 -0
  47. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +7 -1
  48. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  49. package/types/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.d.ts +19 -0
  50. package/types/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.d.ts.map +1 -0
  51. package/bin/adapter/entry-points/console/ui-dist/assets/index-DWiG7btO.js +0 -103
  52. package/src/adapter/entry-points/console/ui-dist/assets/index-DWiG7btO.js +0 -103
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="/assets/index-DWiG7btO.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-BMe1bEF4.css">
7
+ <script type="module" crossorigin src="/assets/index-syKbd_lW.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-D0RwhyOM.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -120,6 +120,8 @@ export class HandleScheduledEventUseCaseHandler {
120
120
  candidateDebounceRecencyWindowSeconds?: number;
121
121
  candidateDebounceStateFilePath?: string;
122
122
  activeHubTaskStatus?: string;
123
+ hubTaskStatusCacheStateFilePath?: string;
124
+ hubTaskStatusCacheTtlSeconds?: number;
123
125
  silentMainStalledMessage?: string;
124
126
  silentSubAgentIdleMessageHeader?: string;
125
127
  silentSubAgentIdleMessageFooter?: string;
@@ -626,6 +628,15 @@ export class HandleScheduledEventUseCaseHandler {
626
628
  process.env.TDPM_ACTIVE_HUB_TASK_STATUS ??
627
629
  null,
628
630
  hubTaskStatusResolver: issueRepository,
631
+ hubTaskStatusCacheStateFilePath:
632
+ mergedInput.hubTaskStatusCacheStateFilePath ??
633
+ process.env.TDPM_SILENT_HUB_TASK_STATUS_CACHE_STATE_FILE_PATH ??
634
+ null,
635
+ hubTaskStatusCacheTtlSeconds: readSilentSeconds(
636
+ mergedInput.hubTaskStatusCacheTtlSeconds,
637
+ process.env.TDPM_SILENT_HUB_TASK_STATUS_CACHE_TTL_SECONDS,
638
+ DEFAULT_NOTIFY_SILENT_TMUX_SESSIONS_PARAMS.hubTaskStatusCacheTtlSeconds,
639
+ ),
629
640
  messageTemplates: {
630
641
  mainStalledMessage:
631
642
  mergedInput.silentMainStalledMessage ??
@@ -39,6 +39,7 @@ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
39
39
  describe('notifySilentTmuxSessions', () => {
40
40
  let configDir: string;
41
41
  let candidateStateFilePath: string;
42
+ let hubTaskStatusCacheStateFilePath: string;
42
43
 
43
44
  beforeEach(() => {
44
45
  jest.spyOn(console, 'log').mockImplementation(() => undefined);
@@ -47,6 +48,10 @@ describe('notifySilentTmuxSessions', () => {
47
48
  configDir,
48
49
  'silent-session-candidates.json',
49
50
  );
51
+ hubTaskStatusCacheStateFilePath = path.join(
52
+ configDir,
53
+ 'silent-session-hub-task-status.json',
54
+ );
50
55
  });
51
56
 
52
57
  afterEach(() => {
@@ -136,6 +141,7 @@ describe('notifySilentTmuxSessions', () => {
136
141
  candidateDebounceStateFilePath: candidateStateFilePath,
137
142
  activeHubTaskStatus: null,
138
143
  hubTaskStatusResolver: null,
144
+ hubTaskStatusCacheStateFilePath: hubTaskStatusCacheStateFilePath,
139
145
  messageTemplates: EMPTY_TEMPLATES,
140
146
  now: NOW,
141
147
  ...DEFAULT_NOTIFY_SILENT_TMUX_SESSIONS_PARAMS,
@@ -10,6 +10,7 @@ import {
10
10
  DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
11
11
  DEFAULT_NOTIFICATION_STAGGER_SECONDS,
12
12
  DEFAULT_CANDIDATE_DEBOUNCE_RECENCY_WINDOW_SECONDS,
13
+ DEFAULT_HUB_TASK_STATUS_CACHE_TTL_SECONDS,
13
14
  } from '../../../domain/usecases/NotifySilentLiveSessionsUseCase';
14
15
  import { DefaultSilentSessionMessageComposer } from '../../../domain/usecases/DefaultSilentSessionMessageComposer';
15
16
  import { LocalProcessLiveSessionProcessSnapshotProvider } from '../../repositories/LocalProcessLiveSessionProcessSnapshotProvider';
@@ -30,6 +31,7 @@ import {
30
31
  } from '../../repositories/ConfigurableSilentSessionMessageComposer';
31
32
  import { RealSleeper } from '../../repositories/RealSleeper';
32
33
  import { FileSystemSilentSessionCandidateStateRepository } from '../../repositories/FileSystemSilentSessionCandidateStateRepository';
34
+ import { FileSystemSilentSessionHubTaskStatusCacheRepository } from '../../repositories/FileSystemSilentSessionHubTaskStatusCacheRepository';
33
35
 
34
36
  export type NotifySilentTmuxSessionsParams = {
35
37
  enabled: boolean;
@@ -47,6 +49,8 @@ export type NotifySilentTmuxSessionsParams = {
47
49
  candidateDebounceStateFilePath: string | null;
48
50
  activeHubTaskStatus: string | null;
49
51
  hubTaskStatusResolver: HubTaskStatusResolver | null;
52
+ hubTaskStatusCacheStateFilePath: string | null;
53
+ hubTaskStatusCacheTtlSeconds: number;
50
54
  messageTemplates: SilentSessionMessageTemplates;
51
55
  now: Date;
52
56
  };
@@ -104,6 +108,8 @@ export const notifySilentTmuxSessions = async (
104
108
  candidateDebounceStateFilePath,
105
109
  activeHubTaskStatus,
106
110
  hubTaskStatusResolver,
111
+ hubTaskStatusCacheStateFilePath,
112
+ hubTaskStatusCacheTtlSeconds,
107
113
  messageTemplates,
108
114
  now,
109
115
  } = params;
@@ -141,6 +147,11 @@ export const notifySilentTmuxSessions = async (
141
147
  messageComposer,
142
148
  new RealSleeper(),
143
149
  hubTaskStatusResolver,
150
+ hubTaskStatusCacheStateFilePath !== null
151
+ ? new FileSystemSilentSessionHubTaskStatusCacheRepository(
152
+ hubTaskStatusCacheStateFilePath,
153
+ )
154
+ : new FileSystemSilentSessionHubTaskStatusCacheRepository(),
144
155
  );
145
156
  await useCase.run({
146
157
  mainSilentThresholdSeconds,
@@ -149,6 +160,7 @@ export const notifySilentTmuxSessions = async (
149
160
  staggerSeconds,
150
161
  candidateDebounceRecencyWindowSeconds,
151
162
  activeHubTaskStatus,
163
+ hubTaskStatusCacheTtlSeconds,
152
164
  now,
153
165
  });
154
166
  };
@@ -160,4 +172,5 @@ export const DEFAULT_NOTIFY_SILENT_TMUX_SESSIONS_PARAMS = {
160
172
  staggerSeconds: DEFAULT_NOTIFICATION_STAGGER_SECONDS,
161
173
  candidateDebounceRecencyWindowSeconds:
162
174
  DEFAULT_CANDIDATE_DEBOUNCE_RECENCY_WINDOW_SECONDS,
175
+ hubTaskStatusCacheTtlSeconds: DEFAULT_HUB_TASK_STATUS_CACHE_TTL_SECONDS,
163
176
  } as const;
@@ -0,0 +1,251 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import {
5
+ DEFAULT_HUB_TASK_STATUS_RETENTION_WINDOW_SECONDS,
6
+ FileSystemSilentSessionHubTaskStatusCacheRepository,
7
+ } from './FileSystemSilentSessionHubTaskStatusCacheRepository';
8
+
9
+ describe('FileSystemSilentSessionHubTaskStatusCacheRepository', () => {
10
+ let rootDirectory: string;
11
+ let stateFilePath: string;
12
+
13
+ beforeEach(() => {
14
+ rootDirectory = fs.mkdtempSync(
15
+ path.join(os.tmpdir(), 'silent-session-hub-task-status-'),
16
+ );
17
+ stateFilePath = path.join(
18
+ rootDirectory,
19
+ 'silent-session-hub-task-status.json',
20
+ );
21
+ });
22
+
23
+ afterEach(() => {
24
+ fs.rmSync(rootDirectory, { force: true, recursive: true });
25
+ });
26
+
27
+ const issueAlpha = 'https://github.com/HiromiShikata/repo/issues/100';
28
+ const issueBravo = 'https://github.com/HiromiShikata/repo/issues/200';
29
+
30
+ it('round-trips a saved status so the next cycle reads it back', async () => {
31
+ const repository = new FileSystemSilentSessionHubTaskStatusCacheRepository(
32
+ stateFilePath,
33
+ );
34
+ const now = new Date('2026-06-26T00:00:00Z');
35
+
36
+ await repository.saveHubTaskStatus({
37
+ url: issueAlpha,
38
+ state: 'OPEN',
39
+ status: 'In tmux',
40
+ now,
41
+ });
42
+ const loaded = await repository.loadHubTaskStatus({ url: issueAlpha });
43
+
44
+ expect(loaded).toEqual({
45
+ url: issueAlpha,
46
+ state: 'OPEN',
47
+ status: 'In tmux',
48
+ recordedEpochSeconds: Math.floor(now.getTime() / 1000),
49
+ });
50
+ });
51
+
52
+ it('preserves a null project status across a round-trip', async () => {
53
+ const repository = new FileSystemSilentSessionHubTaskStatusCacheRepository(
54
+ stateFilePath,
55
+ );
56
+ const now = new Date('2026-06-26T00:00:00Z');
57
+
58
+ await repository.saveHubTaskStatus({
59
+ url: issueAlpha,
60
+ state: 'CLOSED',
61
+ status: null,
62
+ now,
63
+ });
64
+ const loaded = await repository.loadHubTaskStatus({ url: issueAlpha });
65
+
66
+ expect(loaded).toEqual({
67
+ url: issueAlpha,
68
+ state: 'CLOSED',
69
+ status: null,
70
+ recordedEpochSeconds: Math.floor(now.getTime() / 1000),
71
+ });
72
+ });
73
+
74
+ it('returns null when no status has been recorded for the url', async () => {
75
+ const repository = new FileSystemSilentSessionHubTaskStatusCacheRepository(
76
+ stateFilePath,
77
+ );
78
+
79
+ const loaded = await repository.loadHubTaskStatus({ url: issueAlpha });
80
+
81
+ expect(loaded).toBeNull();
82
+ });
83
+
84
+ it('returns null when no state file exists yet', async () => {
85
+ const repository = new FileSystemSilentSessionHubTaskStatusCacheRepository(
86
+ path.join(rootDirectory, 'missing.json'),
87
+ );
88
+
89
+ const loaded = await repository.loadHubTaskStatus({ url: issueAlpha });
90
+
91
+ expect(loaded).toBeNull();
92
+ });
93
+
94
+ it('returns the most recently recorded status when a url is saved again', async () => {
95
+ const repository = new FileSystemSilentSessionHubTaskStatusCacheRepository(
96
+ stateFilePath,
97
+ );
98
+
99
+ await repository.saveHubTaskStatus({
100
+ url: issueAlpha,
101
+ state: 'OPEN',
102
+ status: 'In tmux',
103
+ now: new Date('2026-06-26T00:00:00Z'),
104
+ });
105
+ await repository.saveHubTaskStatus({
106
+ url: issueAlpha,
107
+ state: 'CLOSED',
108
+ status: 'Done',
109
+ now: new Date('2026-06-26T00:01:00Z'),
110
+ });
111
+ const loaded = await repository.loadHubTaskStatus({ url: issueAlpha });
112
+
113
+ expect(loaded?.state).toBe('CLOSED');
114
+ expect(loaded?.status).toBe('Done');
115
+ });
116
+
117
+ it('preserves another url recorded by a concurrent run when saving a different url', async () => {
118
+ const repository = new FileSystemSilentSessionHubTaskStatusCacheRepository(
119
+ stateFilePath,
120
+ );
121
+
122
+ await repository.saveHubTaskStatus({
123
+ url: issueAlpha,
124
+ state: 'OPEN',
125
+ status: 'In tmux',
126
+ now: new Date('2026-06-26T00:00:00Z'),
127
+ });
128
+ await repository.saveHubTaskStatus({
129
+ url: issueBravo,
130
+ state: 'CLOSED',
131
+ status: 'Done',
132
+ now: new Date('2026-06-26T00:00:30Z'),
133
+ });
134
+
135
+ expect(
136
+ (await repository.loadHubTaskStatus({ url: issueAlpha }))?.state,
137
+ ).toBe('OPEN');
138
+ expect(
139
+ (await repository.loadHubTaskStatus({ url: issueBravo }))?.state,
140
+ ).toBe('CLOSED');
141
+ });
142
+
143
+ it('drops a stored entry that has aged beyond the retention window on the next save', async () => {
144
+ const repository = new FileSystemSilentSessionHubTaskStatusCacheRepository(
145
+ stateFilePath,
146
+ 60 * 60,
147
+ );
148
+
149
+ await repository.saveHubTaskStatus({
150
+ url: issueAlpha,
151
+ state: 'OPEN',
152
+ status: 'In tmux',
153
+ now: new Date('2026-06-26T00:00:00Z'),
154
+ });
155
+ await repository.saveHubTaskStatus({
156
+ url: issueBravo,
157
+ state: 'OPEN',
158
+ status: 'In tmux',
159
+ now: new Date('2026-06-26T01:30:00Z'),
160
+ });
161
+
162
+ const persisted: unknown = JSON.parse(
163
+ fs.readFileSync(stateFilePath, 'utf8'),
164
+ );
165
+ expect(persisted).toEqual({
166
+ hubTaskStatuses: [
167
+ {
168
+ url: issueBravo,
169
+ state: 'OPEN',
170
+ status: 'In tmux',
171
+ recordedEpochSeconds: Math.floor(
172
+ new Date('2026-06-26T01:30:00Z').getTime() / 1000,
173
+ ),
174
+ },
175
+ ],
176
+ });
177
+ });
178
+
179
+ it('treats a corrupt state file as no recorded status', async () => {
180
+ fs.writeFileSync(stateFilePath, 'not valid json');
181
+ const repository = new FileSystemSilentSessionHubTaskStatusCacheRepository(
182
+ stateFilePath,
183
+ );
184
+
185
+ const loaded = await repository.loadHubTaskStatus({ url: issueAlpha });
186
+
187
+ expect(loaded).toBeNull();
188
+ });
189
+
190
+ it('ignores a malformed stored entry that lacks a valid state', async () => {
191
+ fs.writeFileSync(
192
+ stateFilePath,
193
+ JSON.stringify({
194
+ hubTaskStatuses: [
195
+ {
196
+ url: issueAlpha,
197
+ state: 'UNKNOWN',
198
+ status: 'In tmux',
199
+ recordedEpochSeconds: 1,
200
+ },
201
+ ],
202
+ }),
203
+ );
204
+ const repository = new FileSystemSilentSessionHubTaskStatusCacheRepository(
205
+ stateFilePath,
206
+ );
207
+
208
+ const loaded = await repository.loadHubTaskStatus({ url: issueAlpha });
209
+
210
+ expect(loaded).toBeNull();
211
+ });
212
+
213
+ it('creates the parent directory when the configured path does not exist yet', async () => {
214
+ const nestedPath = path.join(rootDirectory, 'nested', 'dir', 'state.json');
215
+ const repository = new FileSystemSilentSessionHubTaskStatusCacheRepository(
216
+ nestedPath,
217
+ );
218
+
219
+ await repository.saveHubTaskStatus({
220
+ url: issueAlpha,
221
+ state: 'OPEN',
222
+ status: 'In tmux',
223
+ now: new Date('2026-06-26T00:00:00Z'),
224
+ });
225
+
226
+ expect(fs.existsSync(nestedPath)).toBe(true);
227
+ });
228
+
229
+ it('writes atomically by renaming a temp file into place leaving no temp file behind', async () => {
230
+ const repository = new FileSystemSilentSessionHubTaskStatusCacheRepository(
231
+ stateFilePath,
232
+ );
233
+
234
+ await repository.saveHubTaskStatus({
235
+ url: issueAlpha,
236
+ state: 'OPEN',
237
+ status: 'In tmux',
238
+ now: new Date('2026-06-26T00:00:00Z'),
239
+ });
240
+
241
+ const leftoverTempFiles = fs
242
+ .readdirSync(rootDirectory)
243
+ .filter((name) => name.includes('.tmp'));
244
+ expect(leftoverTempFiles).toEqual([]);
245
+ expect(fs.existsSync(stateFilePath)).toBe(true);
246
+ });
247
+
248
+ it('exposes the default retention window as a named constant', () => {
249
+ expect(DEFAULT_HUB_TASK_STATUS_RETENTION_WINDOW_SECONDS).toBe(60 * 60);
250
+ });
251
+ });
@@ -0,0 +1,121 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { Issue } from '../../domain/entities/Issue';
5
+ import {
6
+ SilentSessionHubTaskStatusCacheEntry,
7
+ SilentSessionHubTaskStatusCacheRepository,
8
+ } from '../../domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository';
9
+
10
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
11
+ typeof value === 'object' && value !== null && !Array.isArray(value);
12
+
13
+ const isIssueState = (value: unknown): value is Issue['state'] =>
14
+ value === 'OPEN' || value === 'CLOSED' || value === 'MERGED';
15
+
16
+ export const DEFAULT_HUB_TASK_STATUS_RETENTION_WINDOW_SECONDS = 60 * 60;
17
+
18
+ const defaultStateFilePath = (): string => {
19
+ const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
20
+ return path.join(base, 'tdpm', 'silent-session-hub-task-status.json');
21
+ };
22
+
23
+ export class FileSystemSilentSessionHubTaskStatusCacheRepository implements SilentSessionHubTaskStatusCacheRepository {
24
+ constructor(
25
+ private readonly stateFilePath: string = defaultStateFilePath(),
26
+ private readonly retentionWindowSeconds: number = DEFAULT_HUB_TASK_STATUS_RETENTION_WINDOW_SECONDS,
27
+ ) {}
28
+
29
+ loadHubTaskStatus = async (params: {
30
+ url: string;
31
+ }): Promise<SilentSessionHubTaskStatusCacheEntry | null> => {
32
+ for (const entry of this.readEntries()) {
33
+ if (entry.url === params.url) {
34
+ return entry;
35
+ }
36
+ }
37
+ return null;
38
+ };
39
+
40
+ saveHubTaskStatus = async (params: {
41
+ url: string;
42
+ state: Issue['state'];
43
+ status: string | null;
44
+ now: Date;
45
+ }): Promise<void> => {
46
+ const recordedEpochSeconds = Math.floor(params.now.getTime() / 1000);
47
+ const oldestRetainedEpochSeconds =
48
+ recordedEpochSeconds - this.retentionWindowSeconds;
49
+ const mergedByUrl = new Map<string, SilentSessionHubTaskStatusCacheEntry>();
50
+ for (const entry of this.readEntries()) {
51
+ if (
52
+ entry.recordedEpochSeconds >= oldestRetainedEpochSeconds &&
53
+ entry.url !== params.url
54
+ ) {
55
+ mergedByUrl.set(entry.url, entry);
56
+ }
57
+ }
58
+ mergedByUrl.set(params.url, {
59
+ url: params.url,
60
+ state: params.state,
61
+ status: params.status,
62
+ recordedEpochSeconds,
63
+ });
64
+ this.writeEntries(Array.from(mergedByUrl.values()));
65
+ };
66
+
67
+ private readEntries = (): SilentSessionHubTaskStatusCacheEntry[] => {
68
+ let raw: string;
69
+ try {
70
+ raw = fs.readFileSync(this.stateFilePath, 'utf8');
71
+ } catch {
72
+ return [];
73
+ }
74
+ let parsed: unknown;
75
+ try {
76
+ parsed = JSON.parse(raw);
77
+ } catch {
78
+ return [];
79
+ }
80
+ if (!isRecord(parsed)) {
81
+ return [];
82
+ }
83
+ const storedEntries = parsed.hubTaskStatuses;
84
+ if (!Array.isArray(storedEntries)) {
85
+ return [];
86
+ }
87
+ const entries: SilentSessionHubTaskStatusCacheEntry[] = [];
88
+ for (const storedEntry of storedEntries) {
89
+ if (!isRecord(storedEntry)) {
90
+ continue;
91
+ }
92
+ const url = storedEntry.url;
93
+ const state = storedEntry.state;
94
+ const status = storedEntry.status;
95
+ const recordedEpochSeconds = storedEntry.recordedEpochSeconds;
96
+ if (
97
+ typeof url === 'string' &&
98
+ isIssueState(state) &&
99
+ (typeof status === 'string' || status === null) &&
100
+ typeof recordedEpochSeconds === 'number' &&
101
+ Number.isFinite(recordedEpochSeconds)
102
+ ) {
103
+ entries.push({ url, state, status, recordedEpochSeconds });
104
+ }
105
+ }
106
+ return entries;
107
+ };
108
+
109
+ private writeEntries = (
110
+ entries: SilentSessionHubTaskStatusCacheEntry[],
111
+ ): void => {
112
+ const directory = path.dirname(this.stateFilePath);
113
+ fs.mkdirSync(directory, { recursive: true });
114
+ const temporaryPath = `${this.stateFilePath}.${process.pid}.tmp`;
115
+ fs.writeFileSync(
116
+ temporaryPath,
117
+ JSON.stringify({ hubTaskStatuses: entries }),
118
+ );
119
+ fs.renameSync(temporaryPath, this.stateFilePath);
120
+ };
121
+ }