github-issue-tower-defence-management 1.148.24 → 1.149.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 (81) hide show
  1. package/.github/workflows/publish.yml +40 -7
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +20 -0
  4. package/README.md +4 -4
  5. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +2 -1
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  7. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +1 -0
  8. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
  9. package/bin/adapter/repositories/FileSystemKevReportWatermarkRepository.js +126 -0
  10. package/bin/adapter/repositories/FileSystemKevReportWatermarkRepository.js.map +1 -0
  11. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js +1 -0
  12. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js.map +1 -1
  13. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +5 -3
  14. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
  15. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +6 -2
  16. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  17. package/bin/domain/entities/KevReportWatermark.js +3 -0
  18. package/bin/domain/entities/KevReportWatermark.js.map +1 -0
  19. package/bin/domain/usecases/DailySecurityScanUseCase.js +56 -7
  20. package/bin/domain/usecases/DailySecurityScanUseCase.js.map +1 -1
  21. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +9 -0
  22. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
  23. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +12 -11
  24. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  25. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +19 -0
  26. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  27. package/bin/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.js +3 -0
  28. package/bin/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.js.map +1 -0
  29. package/package.json +1 -1
  30. package/scripts/defaultBranchTipVerify.sh +23 -0
  31. package/scripts/testWorkflowRunVerify.sh +47 -0
  32. package/src/adapter/ci/publishTestWorkflowGate.test.ts +653 -0
  33. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +2 -0
  34. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +35 -0
  35. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +7 -0
  36. package/src/adapter/repositories/FileSystemKevReportWatermarkRepository.test.ts +253 -0
  37. package/src/adapter/repositories/FileSystemKevReportWatermarkRepository.ts +130 -0
  38. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.test.ts +2 -0
  39. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.ts +1 -0
  40. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +3 -0
  41. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +159 -0
  42. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +7 -1
  43. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +108 -0
  44. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +10 -2
  45. package/src/domain/entities/KevReportWatermark.ts +4 -0
  46. package/src/domain/entities/LiveSessionActivitySnapshot.ts +1 -0
  47. package/src/domain/usecases/DailySecurityScanUseCase.test.ts +210 -0
  48. package/src/domain/usecases/DailySecurityScanUseCase.ts +84 -7
  49. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +58 -1
  50. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +21 -0
  51. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +28 -0
  52. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +146 -8
  53. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +19 -10
  54. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +212 -0
  55. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +32 -0
  56. package/src/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.ts +11 -0
  57. package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +3 -0
  58. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  59. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +2 -0
  60. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
  61. package/types/adapter/repositories/FileSystemKevReportWatermarkRepository.d.ts +10 -0
  62. package/types/adapter/repositories/FileSystemKevReportWatermarkRepository.d.ts.map +1 -0
  63. package/types/adapter/repositories/ProcessListSessionSubAgentActivityRepository.d.ts.map +1 -1
  64. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
  65. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -1
  66. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  67. package/types/domain/entities/KevReportWatermark.d.ts +5 -0
  68. package/types/domain/entities/KevReportWatermark.d.ts.map +1 -0
  69. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts +1 -0
  70. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts.map +1 -1
  71. package/types/domain/usecases/DailySecurityScanUseCase.d.ts +3 -1
  72. package/types/domain/usecases/DailySecurityScanUseCase.d.ts.map +1 -1
  73. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +2 -0
  74. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
  75. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  76. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +3 -2
  77. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  78. package/types/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.d.ts +15 -0
  79. package/types/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.d.ts.map +1 -0
  80. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +1 -0
  81. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts.map +1 -1
@@ -10,6 +10,9 @@ const createFallback = (): Mocked<SilentSessionMessageComposer> => ({
10
10
  .fn()
11
11
  .mockReturnValue('FALLBACK_STALE_OWNER_CALL'),
12
12
  composeSubAgentSection: jest.fn().mockReturnValue('FALLBACK_SUB'),
13
+ composeSubAgentUnconsumedResultSection: jest
14
+ .fn()
15
+ .mockReturnValue('FALLBACK_UNCONSUMED_RESULT'),
13
16
  });
14
17
 
15
18
  const noTemplates = {
@@ -142,6 +145,7 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
142
145
  silentSeconds: 360,
143
146
  runningSeconds: 1200,
144
147
  waitingOnExternalProcess: false,
148
+ finishedResultUnconsumed: false,
145
149
  };
146
150
  expect(
147
151
  composer.composeSubAgentSection({
@@ -172,6 +176,7 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
172
176
  silentSeconds: 360,
173
177
  runningSeconds: 60,
174
178
  waitingOnExternalProcess: false,
179
+ finishedResultUnconsumed: false,
175
180
  },
176
181
  ],
177
182
  longRunningSubAgents: [],
@@ -202,6 +207,7 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
202
207
  silentSeconds: 30,
203
208
  runningSeconds: 1200,
204
209
  waitingOnExternalProcess: false,
210
+ finishedResultUnconsumed: false,
205
211
  },
206
212
  ],
207
213
  });
@@ -228,6 +234,7 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
228
234
  silentSeconds: 360,
229
235
  runningSeconds: 1200,
230
236
  waitingOnExternalProcess: false,
237
+ finishedResultUnconsumed: false,
231
238
  };
232
239
  const section = composer.composeSubAgentSection({
233
240
  idleSubAgents: [subAgent],
@@ -257,6 +264,7 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
257
264
  silentSeconds: 360,
258
265
  runningSeconds: 60,
259
266
  waitingOnExternalProcess: false,
267
+ finishedResultUnconsumed: false,
260
268
  };
261
269
  const section = composer.composeSubAgentSection({
262
270
  idleSubAgents: [idleSubAgent],
@@ -269,6 +277,33 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
269
277
  });
270
278
  });
271
279
 
280
+ it('delegates the unconsumed-result section to the fallback composer because no template governs it', () => {
281
+ const fallback = createFallback();
282
+ const composer = new ConfigurableSilentSessionMessageComposer(
283
+ {
284
+ ...noTemplates,
285
+ subAgentIdleMessageHeader: 'CUSTOM_IDLE_HEADER',
286
+ },
287
+ fallback,
288
+ );
289
+ const unconsumedResultSubAgent = {
290
+ label: 'agent-aaabbbbcccc30001',
291
+ silentSeconds: 5220,
292
+ runningSeconds: 5400,
293
+ waitingOnExternalProcess: false,
294
+ finishedResultUnconsumed: true,
295
+ };
296
+
297
+ const section = composer.composeSubAgentUnconsumedResultSection([
298
+ unconsumedResultSubAgent,
299
+ ]);
300
+
301
+ expect(section).toBe('FALLBACK_UNCONSUMED_RESULT');
302
+ expect(
303
+ fallback.composeSubAgentUnconsumedResultSection,
304
+ ).toHaveBeenCalledWith([unconsumedResultSubAgent]);
305
+ });
306
+
272
307
  it('does not double-prepend the sentinel when the template already carries it', () => {
273
308
  const fallback = createFallback();
274
309
  const composer = new ConfigurableSilentSessionMessageComposer(
@@ -99,6 +99,13 @@ export class ConfigurableSilentSessionMessageComposer implements SilentSessionMe
99
99
  return withReminderSentinel(sections.join('\n\n'));
100
100
  };
101
101
 
102
+ composeSubAgentUnconsumedResultSection = (
103
+ unconsumedResultSubAgents: SubAgentActivity[],
104
+ ): string =>
105
+ this.fallback.composeSubAgentUnconsumedResultSection(
106
+ unconsumedResultSubAgents,
107
+ );
108
+
102
109
  private composeIdleSection = (
103
110
  idleSubAgents: SubAgentActivity[],
104
111
  header: string | null,
@@ -0,0 +1,253 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { FileSystemKevReportWatermarkRepository } from './FileSystemKevReportWatermarkRepository';
5
+
6
+ describe('FileSystemKevReportWatermarkRepository', () => {
7
+ let temporaryDirectory: string;
8
+ let stateFilePath: string;
9
+ let errorLog: jest.SpyInstance;
10
+
11
+ beforeEach(() => {
12
+ temporaryDirectory = fs.mkdtempSync(
13
+ path.join(os.tmpdir(), 'kev-report-watermark-'),
14
+ );
15
+ stateFilePath = path.join(
16
+ temporaryDirectory,
17
+ 'nested',
18
+ 'kev-report-watermark.json',
19
+ );
20
+ errorLog = jest.spyOn(console, 'error').mockImplementation(() => undefined);
21
+ });
22
+
23
+ afterEach(() => {
24
+ errorLog.mockRestore();
25
+ fs.rmSync(temporaryDirectory, { recursive: true, force: true });
26
+ });
27
+
28
+ const writeStoredContent = (content: string): void => {
29
+ fs.mkdirSync(path.dirname(stateFilePath), { recursive: true });
30
+ fs.writeFileSync(stateFilePath, content);
31
+ };
32
+
33
+ const loggedMessages = (): string[] =>
34
+ errorLog.mock.calls.map((call: unknown[]) => String(call[0]));
35
+
36
+ it('reports an absent state file as absent without logging an error', async () => {
37
+ const repository = new FileSystemKevReportWatermarkRepository(
38
+ stateFilePath,
39
+ );
40
+ expect(await repository.load()).toEqual({ type: 'absent' });
41
+ expect(loggedMessages()).toEqual([]);
42
+ });
43
+
44
+ it('persists and reloads the watermark', async () => {
45
+ const repository = new FileSystemKevReportWatermarkRepository(
46
+ stateFilePath,
47
+ );
48
+ await repository.save({
49
+ lastReportedDateAdded: '2024-01-02',
50
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0002'],
51
+ });
52
+
53
+ expect(await repository.load()).toEqual({
54
+ type: 'stored',
55
+ watermark: {
56
+ lastReportedDateAdded: '2024-01-02',
57
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0002'],
58
+ },
59
+ });
60
+ expect(loggedMessages()).toEqual([]);
61
+ });
62
+
63
+ it('overwrites a previously saved watermark', async () => {
64
+ const repository = new FileSystemKevReportWatermarkRepository(
65
+ stateFilePath,
66
+ );
67
+ await repository.save({
68
+ lastReportedDateAdded: '2024-01-02',
69
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0002'],
70
+ });
71
+ await repository.save({
72
+ lastReportedDateAdded: '2024-01-03',
73
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0003'],
74
+ });
75
+
76
+ expect(await repository.load()).toEqual({
77
+ type: 'stored',
78
+ watermark: {
79
+ lastReportedDateAdded: '2024-01-03',
80
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0003'],
81
+ },
82
+ });
83
+ });
84
+
85
+ it('reports invalid JSON as unreadable, logging the state file path and the cause, and leaves the file untouched', async () => {
86
+ writeStoredContent('not json');
87
+ const repository = new FileSystemKevReportWatermarkRepository(
88
+ stateFilePath,
89
+ );
90
+
91
+ const result = await repository.load();
92
+
93
+ expect(result.type).toBe('unreadable');
94
+ expect(result).toHaveProperty('reason');
95
+ expect(
96
+ loggedMessages().some(
97
+ (message) =>
98
+ message.includes(stateFilePath) &&
99
+ message.includes('valid JSON') &&
100
+ message.includes('SyntaxError'),
101
+ ),
102
+ ).toBe(true);
103
+ expect(fs.readFileSync(stateFilePath, 'utf8')).toBe('not json');
104
+ });
105
+
106
+ it('reports stored content that is not a JSON object as unreadable', async () => {
107
+ writeStoredContent(JSON.stringify(['2024-01-02']));
108
+ const repository = new FileSystemKevReportWatermarkRepository(
109
+ stateFilePath,
110
+ );
111
+
112
+ expect((await repository.load()).type).toBe('unreadable');
113
+ expect(
114
+ loggedMessages().some(
115
+ (message) =>
116
+ message.includes(stateFilePath) && message.includes('JSON object'),
117
+ ),
118
+ ).toBe(true);
119
+ });
120
+
121
+ it('reports a non-string stored date as unreadable', async () => {
122
+ writeStoredContent(
123
+ JSON.stringify({
124
+ lastReportedDateAdded: 20240102,
125
+ reportedCveIdsOnLastReportedDateAdded: [],
126
+ }),
127
+ );
128
+ const repository = new FileSystemKevReportWatermarkRepository(
129
+ stateFilePath,
130
+ );
131
+
132
+ expect((await repository.load()).type).toBe('unreadable');
133
+ expect(
134
+ loggedMessages().some(
135
+ (message) =>
136
+ message.includes(stateFilePath) &&
137
+ message.includes('lastReportedDateAdded'),
138
+ ),
139
+ ).toBe(true);
140
+ });
141
+
142
+ it('reports an empty stored date as unreadable rather than as a usable boundary', async () => {
143
+ writeStoredContent(
144
+ JSON.stringify({
145
+ lastReportedDateAdded: '',
146
+ reportedCveIdsOnLastReportedDateAdded: [],
147
+ }),
148
+ );
149
+ const repository = new FileSystemKevReportWatermarkRepository(
150
+ stateFilePath,
151
+ );
152
+
153
+ expect((await repository.load()).type).toBe('unreadable');
154
+ expect(
155
+ loggedMessages().some(
156
+ (message) =>
157
+ message.includes(stateFilePath) &&
158
+ message.includes('calendar date') &&
159
+ message.includes('YYYY-MM-DD'),
160
+ ),
161
+ ).toBe(true);
162
+ });
163
+
164
+ it('reports a stored date that is not a real calendar date as unreadable', async () => {
165
+ writeStoredContent(
166
+ JSON.stringify({
167
+ lastReportedDateAdded: '2024-02-31',
168
+ reportedCveIdsOnLastReportedDateAdded: [],
169
+ }),
170
+ );
171
+ const repository = new FileSystemKevReportWatermarkRepository(
172
+ stateFilePath,
173
+ );
174
+
175
+ expect((await repository.load()).type).toBe('unreadable');
176
+ expect(
177
+ loggedMessages().some((message) => message.includes('calendar date')),
178
+ ).toBe(true);
179
+ });
180
+
181
+ it('reports a stored identifier list that is not an array as unreadable', async () => {
182
+ writeStoredContent(
183
+ JSON.stringify({
184
+ lastReportedDateAdded: '2024-01-02',
185
+ reportedCveIdsOnLastReportedDateAdded: 'CVE-2024-0002',
186
+ }),
187
+ );
188
+ const repository = new FileSystemKevReportWatermarkRepository(
189
+ stateFilePath,
190
+ );
191
+
192
+ expect((await repository.load()).type).toBe('unreadable');
193
+ expect(
194
+ loggedMessages().some((message) =>
195
+ message.includes('reportedCveIdsOnLastReportedDateAdded'),
196
+ ),
197
+ ).toBe(true);
198
+ });
199
+
200
+ it('reports stored identifiers that are not all strings as unreadable', async () => {
201
+ writeStoredContent(
202
+ JSON.stringify({
203
+ lastReportedDateAdded: '2024-01-02',
204
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0002', 7],
205
+ }),
206
+ );
207
+ const repository = new FileSystemKevReportWatermarkRepository(
208
+ stateFilePath,
209
+ );
210
+
211
+ expect((await repository.load()).type).toBe('unreadable');
212
+ expect(
213
+ loggedMessages().some((message) => message.includes('non-string')),
214
+ ).toBe(true);
215
+ });
216
+
217
+ it('reports a path that cannot be read as unreadable rather than as absent', async () => {
218
+ fs.mkdirSync(stateFilePath, { recursive: true });
219
+ const repository = new FileSystemKevReportWatermarkRepository(
220
+ stateFilePath,
221
+ );
222
+
223
+ expect((await repository.load()).type).toBe('unreadable');
224
+ expect(
225
+ loggedMessages().some(
226
+ (message) =>
227
+ message.includes(stateFilePath) &&
228
+ message.includes('could not be read'),
229
+ ),
230
+ ).toBe(true);
231
+ });
232
+
233
+ it('logs the state file path and the cause when the watermark cannot be written', async () => {
234
+ fs.writeFileSync(path.dirname(stateFilePath), 'not a directory');
235
+ const repository = new FileSystemKevReportWatermarkRepository(
236
+ stateFilePath,
237
+ );
238
+
239
+ await expect(
240
+ repository.save({
241
+ lastReportedDateAdded: '2024-01-02',
242
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0002'],
243
+ }),
244
+ ).rejects.toThrow();
245
+ expect(
246
+ loggedMessages().some(
247
+ (message) =>
248
+ message.includes(stateFilePath) &&
249
+ message.includes('Unable to write the KEV report watermark'),
250
+ ),
251
+ ).toBe(true);
252
+ });
253
+ });
@@ -0,0 +1,130 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { KevReportWatermark } from '../../domain/entities/KevReportWatermark';
5
+ import {
6
+ KevReportWatermarkLoadResult,
7
+ KevReportWatermarkRepository,
8
+ } from '../../domain/usecases/adapter-interfaces/KevReportWatermarkRepository';
9
+
10
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
11
+ typeof value === 'object' && value !== null && !Array.isArray(value);
12
+
13
+ const errorCode = (error: unknown): string | null => {
14
+ if (!isRecord(error)) {
15
+ return null;
16
+ }
17
+ const code = error.code;
18
+ return typeof code === 'string' ? code : null;
19
+ };
20
+
21
+ const isCalendarYmd = (value: string): boolean => {
22
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
23
+ return false;
24
+ }
25
+ const parsed = new Date(`${value}T00:00:00.000Z`);
26
+ return (
27
+ !Number.isNaN(parsed.getTime()) &&
28
+ parsed.toISOString().slice(0, 10) === value
29
+ );
30
+ };
31
+
32
+ const defaultStateFilePath = (): string => {
33
+ const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
34
+ return path.join(base, 'tdpm', 'kev-report-watermark.json');
35
+ };
36
+
37
+ export class FileSystemKevReportWatermarkRepository implements KevReportWatermarkRepository {
38
+ constructor(
39
+ private readonly stateFilePath: string = defaultStateFilePath(),
40
+ ) {}
41
+
42
+ load = async (): Promise<KevReportWatermarkLoadResult> => {
43
+ let raw: string;
44
+ try {
45
+ raw = fs.readFileSync(this.stateFilePath, 'utf8');
46
+ } catch (error) {
47
+ if (errorCode(error) === 'ENOENT') {
48
+ return { type: 'absent' };
49
+ }
50
+ return this.unreadable(
51
+ `the stored watermark file could not be read (${String(error)})`,
52
+ );
53
+ }
54
+
55
+ let parsed: unknown;
56
+ try {
57
+ parsed = JSON.parse(raw);
58
+ } catch (error) {
59
+ return this.unreadable(
60
+ `the stored watermark file does not contain valid JSON (${String(error)})`,
61
+ );
62
+ }
63
+
64
+ if (!isRecord(parsed)) {
65
+ return this.unreadable(
66
+ 'the stored watermark file does not contain a JSON object',
67
+ );
68
+ }
69
+
70
+ const lastReportedDateAdded = parsed.lastReportedDateAdded;
71
+ if (typeof lastReportedDateAdded !== 'string') {
72
+ return this.unreadable(
73
+ `the stored lastReportedDateAdded is not a string (${JSON.stringify(lastReportedDateAdded)})`,
74
+ );
75
+ }
76
+ if (!isCalendarYmd(lastReportedDateAdded)) {
77
+ return this.unreadable(
78
+ `the stored lastReportedDateAdded is not a calendar date in YYYY-MM-DD form (${JSON.stringify(lastReportedDateAdded)})`,
79
+ );
80
+ }
81
+
82
+ const reportedCveIdsOnLastReportedDateAdded =
83
+ parsed.reportedCveIdsOnLastReportedDateAdded;
84
+ if (!Array.isArray(reportedCveIdsOnLastReportedDateAdded)) {
85
+ return this.unreadable(
86
+ 'the stored reportedCveIdsOnLastReportedDateAdded is not an array',
87
+ );
88
+ }
89
+ if (
90
+ !reportedCveIdsOnLastReportedDateAdded.every(
91
+ (cveId) => typeof cveId === 'string',
92
+ )
93
+ ) {
94
+ return this.unreadable(
95
+ 'the stored reportedCveIdsOnLastReportedDateAdded contains a non-string entry',
96
+ );
97
+ }
98
+
99
+ return {
100
+ type: 'stored',
101
+ watermark: {
102
+ lastReportedDateAdded,
103
+ reportedCveIdsOnLastReportedDateAdded:
104
+ reportedCveIdsOnLastReportedDateAdded,
105
+ },
106
+ };
107
+ };
108
+
109
+ save = async (watermark: KevReportWatermark): Promise<void> => {
110
+ try {
111
+ const directory = path.dirname(this.stateFilePath);
112
+ fs.mkdirSync(directory, { recursive: true });
113
+ const temporaryPath = `${this.stateFilePath}.${process.pid}.tmp`;
114
+ fs.writeFileSync(temporaryPath, JSON.stringify(watermark));
115
+ fs.renameSync(temporaryPath, this.stateFilePath);
116
+ } catch (error) {
117
+ console.error(
118
+ `Unable to write the KEV report watermark to ${this.stateFilePath}: ${String(error)}`,
119
+ );
120
+ throw error;
121
+ }
122
+ };
123
+
124
+ private unreadable = (reason: string): KevReportWatermarkLoadResult => {
125
+ console.error(
126
+ `Unable to use the KEV report watermark stored at ${this.stateFilePath}: ${reason}. The file is left untouched for inspection.`,
127
+ );
128
+ return { type: 'unreadable', reason };
129
+ };
130
+ }
@@ -59,6 +59,7 @@ describe('ProcessListSessionSubAgentActivityRepository', () => {
59
59
  silentSeconds: 180,
60
60
  runningSeconds: 1200,
61
61
  waitingOnExternalProcess: false,
62
+ finishedResultUnconsumed: false,
62
63
  },
63
64
  ]);
64
65
  });
@@ -88,6 +89,7 @@ describe('ProcessListSessionSubAgentActivityRepository', () => {
88
89
  silentSeconds: 0,
89
90
  runningSeconds: 30,
90
91
  waitingOnExternalProcess: false,
92
+ finishedResultUnconsumed: false,
91
93
  },
92
94
  ]);
93
95
  });
@@ -44,6 +44,7 @@ export class ProcessListSessionSubAgentActivityRepository implements SessionSubA
44
44
  silentSeconds: this.silentSecondsResolver.resolveSilentSeconds(label),
45
45
  runningSeconds: process.elapsedSeconds,
46
46
  waitingOnExternalProcess: false,
47
+ finishedResultUnconsumed: false,
47
48
  };
48
49
  const existing = result.get(sessionName);
49
50
  if (existing === undefined) {
@@ -940,6 +940,9 @@ describe('TranscriptOwnerCallStatusProvider wired into NotifySilentLiveSessionsU
940
940
  .fn()
941
941
  .mockReturnValue('MAIN_STALLED_STALE'),
942
942
  composeSubAgentSection: jest.fn().mockReturnValue('SUBAGENT'),
943
+ composeSubAgentUnconsumedResultSection: jest
944
+ .fn()
945
+ .mockReturnValue('SUBAGENT_UNCONSUMED'),
943
946
  },
944
947
  { sleep: jest.fn().mockResolvedValue(undefined) },
945
948
  );