github-issue-tower-defence-management 1.117.10 → 1.118.1

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 (45) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +11 -7
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +38 -12
  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/ConfigurableSilentSessionMessageComposer.js +44 -11
  8. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
  9. package/bin/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.js +128 -0
  10. package/bin/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.js.map +1 -0
  11. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +27 -7
  12. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
  13. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +65 -17
  14. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  15. package/bin/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.js +3 -0
  16. package/bin/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.js.map +1 -0
  17. package/package.json +1 -1
  18. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +29 -8
  19. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +14 -4
  20. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +13 -0
  21. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +81 -28
  22. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +91 -19
  23. package/src/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.test.ts +251 -0
  24. package/src/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.ts +121 -0
  25. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +91 -15
  26. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +54 -11
  27. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +252 -9
  28. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +111 -20
  29. package/src/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.ts +20 -0
  30. package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +9 -1
  31. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  32. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -0
  33. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  34. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +8 -4
  35. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
  36. package/types/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.d.ts +20 -0
  37. package/types/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.d.ts.map +1 -0
  38. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +2 -2
  39. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
  40. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +7 -1
  41. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  42. package/types/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.d.ts +19 -0
  43. package/types/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.d.ts.map +1 -0
  44. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +5 -1
  45. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts.map +1 -1
@@ -4,20 +4,29 @@ import { SILENT_SESSION_REMINDER_SENTINEL } from '../../domain/usecases/silentSe
4
4
 
5
5
  type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
6
6
 
7
+ const THRESHOLDS = {
8
+ subAgentSilentThresholdSeconds: 300,
9
+ subAgentRunningThresholdSeconds: 900,
10
+ };
11
+
7
12
  const createFallback = (): Mocked<SilentSessionMessageComposer> => ({
8
13
  composeMainStalledSection: jest.fn().mockReturnValue('FALLBACK_MAIN'),
9
14
  composeSubAgentSection: jest.fn().mockReturnValue('FALLBACK_SUB'),
10
15
  });
11
16
 
17
+ const noTemplates = {
18
+ mainStalledMessage: null,
19
+ subAgentIdleMessageHeader: null,
20
+ subAgentIdleMessageFooter: null,
21
+ subAgentLongRunningMessageHeader: null,
22
+ subAgentLongRunningMessageFooter: null,
23
+ };
24
+
12
25
  describe('ConfigurableSilentSessionMessageComposer', () => {
13
26
  it('uses the fallback main section when no main template is configured', () => {
14
27
  const fallback = createFallback();
15
28
  const composer = new ConfigurableSilentSessionMessageComposer(
16
- {
17
- mainStalledMessage: null,
18
- subAgentMessageHeader: null,
19
- subAgentMessageFooter: null,
20
- },
29
+ noTemplates,
21
30
  fallback,
22
31
  );
23
32
  expect(composer.composeMainStalledSection(600)).toBe('FALLBACK_MAIN');
@@ -28,9 +37,8 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
28
37
  const fallback = createFallback();
29
38
  const composer = new ConfigurableSilentSessionMessageComposer(
30
39
  {
40
+ ...noTemplates,
31
41
  mainStalledMessage: 'CUSTOM_MAIN',
32
- subAgentMessageHeader: null,
33
- subAgentMessageFooter: null,
34
42
  },
35
43
  fallback,
36
44
  );
@@ -40,52 +48,97 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
40
48
  expect(fallback.composeMainStalledSection).not.toHaveBeenCalled();
41
49
  });
42
50
 
43
- it('uses the fallback sub-agent section when neither header nor footer is configured', () => {
51
+ it('uses the fallback sub-agent section when no sub-agent template is configured', () => {
44
52
  const fallback = createFallback();
45
53
  const composer = new ConfigurableSilentSessionMessageComposer(
46
- {
47
- mainStalledMessage: null,
48
- subAgentMessageHeader: null,
49
- subAgentMessageFooter: null,
50
- },
54
+ noTemplates,
51
55
  fallback,
52
56
  );
53
57
  expect(
54
- composer.composeSubAgentSection([
55
- { label: 'task-a', silentSeconds: 360, runningSeconds: 1200 },
56
- ]),
58
+ composer.composeSubAgentSection(
59
+ [{ label: 'task-a', silentSeconds: 360, runningSeconds: 1200 }],
60
+ THRESHOLDS,
61
+ ),
57
62
  ).toBe('FALLBACK_SUB');
63
+ expect(fallback.composeSubAgentSection).toHaveBeenCalledWith(
64
+ [{ label: 'task-a', silentSeconds: 360, runningSeconds: 1200 }],
65
+ THRESHOLDS,
66
+ );
58
67
  });
59
68
 
60
- it('renders the configured sub-agent header, list, and footer', () => {
69
+ it('renders the configured idle header, list, and footer for an idle sub-agent', () => {
61
70
  const fallback = createFallback();
62
71
  const composer = new ConfigurableSilentSessionMessageComposer(
63
72
  {
64
- mainStalledMessage: null,
65
- subAgentMessageHeader: 'HEADER',
66
- subAgentMessageFooter: 'FOOTER',
73
+ ...noTemplates,
74
+ subAgentIdleMessageHeader: 'IDLE_HEADER',
75
+ subAgentIdleMessageFooter: 'IDLE_FOOTER',
67
76
  },
68
77
  fallback,
69
78
  );
70
- const section = composer.composeSubAgentSection([
71
- { label: 'task-a', silentSeconds: 360, runningSeconds: 1200 },
72
- ]);
73
- expect(section).toContain('HEADER');
79
+ const section = composer.composeSubAgentSection(
80
+ [{ label: 'task-a', silentSeconds: 360, runningSeconds: 60 }],
81
+ THRESHOLDS,
82
+ );
83
+ expect(section).toContain('IDLE_HEADER');
74
84
  expect(section).toContain('task-a');
75
- expect(section).toContain('silent for 6m');
85
+ expect(section).toContain('no output for 6m');
86
+ expect(section).toContain('IDLE_FOOTER');
87
+ expect(section).toContain(SILENT_SESSION_REMINDER_SENTINEL);
88
+ expect(fallback.composeSubAgentSection).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it('renders the configured long-running header, list, and footer for a long-running sub-agent', () => {
92
+ const fallback = createFallback();
93
+ const composer = new ConfigurableSilentSessionMessageComposer(
94
+ {
95
+ ...noTemplates,
96
+ subAgentLongRunningMessageHeader: 'LONG_HEADER',
97
+ subAgentLongRunningMessageFooter: 'LONG_FOOTER',
98
+ },
99
+ fallback,
100
+ );
101
+ const section = composer.composeSubAgentSection(
102
+ [{ label: 'task-b', silentSeconds: 30, runningSeconds: 1200 }],
103
+ THRESHOLDS,
104
+ );
105
+ expect(section).toContain('LONG_HEADER');
106
+ expect(section).toContain('task-b');
76
107
  expect(section).toContain('running for 20m');
77
- expect(section).toContain('FOOTER');
108
+ expect(section).toContain('LONG_FOOTER');
78
109
  expect(section).toContain(SILENT_SESSION_REMINDER_SENTINEL);
79
110
  expect(fallback.composeSubAgentSection).not.toHaveBeenCalled();
80
111
  });
81
112
 
113
+ it('emits both distinct configured sections for a sub-agent matching both conditions', () => {
114
+ const fallback = createFallback();
115
+ const composer = new ConfigurableSilentSessionMessageComposer(
116
+ {
117
+ ...noTemplates,
118
+ subAgentIdleMessageHeader: 'IDLE_HEADER',
119
+ subAgentLongRunningMessageHeader: 'LONG_HEADER',
120
+ },
121
+ fallback,
122
+ );
123
+ const section = composer.composeSubAgentSection(
124
+ [{ label: 'task-both', silentSeconds: 360, runningSeconds: 1200 }],
125
+ THRESHOLDS,
126
+ );
127
+ const [idleSection, longRunningSection] = section
128
+ .replace(`${SILENT_SESSION_REMINDER_SENTINEL} `, '')
129
+ .split('\n\n');
130
+ expect(idleSection).toContain('IDLE_HEADER');
131
+ expect(idleSection).toContain('no output for 6m');
132
+ expect(longRunningSection).toContain('LONG_HEADER');
133
+ expect(longRunningSection).toContain('running for 20m');
134
+ });
135
+
82
136
  it('does not double-prepend the sentinel when the template already carries it', () => {
83
137
  const fallback = createFallback();
84
138
  const composer = new ConfigurableSilentSessionMessageComposer(
85
139
  {
140
+ ...noTemplates,
86
141
  mainStalledMessage: `${SILENT_SESSION_REMINDER_SENTINEL} CUSTOM_MAIN`,
87
- subAgentMessageHeader: null,
88
- subAgentMessageFooter: null,
89
142
  },
90
143
  fallback,
91
144
  );
@@ -1,5 +1,8 @@
1
1
  import { SubAgentActivity } from '../../domain/entities/LiveSessionActivitySnapshot';
2
- import { SilentSessionMessageComposer } from '../../domain/usecases/adapter-interfaces/SilentSessionMessageComposer';
2
+ import {
3
+ SilentSessionMessageComposer,
4
+ SubAgentStallThresholds,
5
+ } from '../../domain/usecases/adapter-interfaces/SilentSessionMessageComposer';
3
6
  import { SILENT_SESSION_REMINDER_SENTINEL } from '../../domain/usecases/silentSessionReminderSentinel';
4
7
 
5
8
  const withReminderSentinel = (message: string): string =>
@@ -9,8 +12,10 @@ const withReminderSentinel = (message: string): string =>
9
12
 
10
13
  export type SilentSessionMessageTemplates = {
11
14
  mainStalledMessage: string | null;
12
- subAgentMessageHeader: string | null;
13
- subAgentMessageFooter: string | null;
15
+ subAgentIdleMessageHeader: string | null;
16
+ subAgentIdleMessageFooter: string | null;
17
+ subAgentLongRunningMessageHeader: string | null;
18
+ subAgentLongRunningMessageFooter: string | null;
14
19
  };
15
20
 
16
21
  const formatMinutes = (seconds: number): string => {
@@ -31,27 +36,94 @@ export class ConfigurableSilentSessionMessageComposer implements SilentSessionMe
31
36
  return withReminderSentinel(this.templates.mainStalledMessage);
32
37
  };
33
38
 
34
- composeSubAgentSection = (subAgents: SubAgentActivity[]): string => {
35
- if (
36
- this.templates.subAgentMessageHeader === null &&
37
- this.templates.subAgentMessageFooter === null
38
- ) {
39
- return this.fallback.composeSubAgentSection(subAgents);
39
+ composeSubAgentSection = (
40
+ subAgents: SubAgentActivity[],
41
+ thresholds: SubAgentStallThresholds,
42
+ ): string => {
43
+ const hasIdleTemplate =
44
+ this.templates.subAgentIdleMessageHeader !== null ||
45
+ this.templates.subAgentIdleMessageFooter !== null;
46
+ const hasLongRunningTemplate =
47
+ this.templates.subAgentLongRunningMessageHeader !== null ||
48
+ this.templates.subAgentLongRunningMessageFooter !== null;
49
+ if (!hasIdleTemplate && !hasLongRunningTemplate) {
50
+ return this.fallback.composeSubAgentSection(subAgents, thresholds);
40
51
  }
41
- const lines = subAgents.map(
52
+
53
+ const idleSubAgents = subAgents.filter(
54
+ (subAgent) =>
55
+ subAgent.silentSeconds >= thresholds.subAgentSilentThresholdSeconds,
56
+ );
57
+ const longRunningSubAgents = subAgents.filter(
42
58
  (subAgent) =>
43
- `- ${subAgent.label}: silent for ${formatMinutes(
44
- subAgent.silentSeconds,
45
- )}, running for ${formatMinutes(subAgent.runningSeconds)}`,
59
+ subAgent.runningSeconds >= thresholds.subAgentRunningThresholdSeconds,
46
60
  );
61
+
47
62
  const sections: string[] = [];
48
- if (this.templates.subAgentMessageHeader !== null) {
49
- sections.push(this.templates.subAgentMessageHeader);
63
+ if (idleSubAgents.length > 0 && hasIdleTemplate) {
64
+ sections.push(
65
+ this.composeIdleSection(
66
+ idleSubAgents,
67
+ this.templates.subAgentIdleMessageHeader,
68
+ this.templates.subAgentIdleMessageFooter,
69
+ ),
70
+ );
71
+ } else if (idleSubAgents.length > 0) {
72
+ sections.push(this.composeIdleSection(idleSubAgents, null, null));
73
+ }
74
+ if (longRunningSubAgents.length > 0 && hasLongRunningTemplate) {
75
+ sections.push(
76
+ this.composeLongRunningSection(
77
+ longRunningSubAgents,
78
+ this.templates.subAgentLongRunningMessageHeader,
79
+ this.templates.subAgentLongRunningMessageFooter,
80
+ ),
81
+ );
82
+ } else if (longRunningSubAgents.length > 0) {
83
+ sections.push(
84
+ this.composeLongRunningSection(longRunningSubAgents, null, null),
85
+ );
86
+ }
87
+ return withReminderSentinel(sections.join('\n\n'));
88
+ };
89
+
90
+ private composeIdleSection = (
91
+ idleSubAgents: SubAgentActivity[],
92
+ header: string | null,
93
+ footer: string | null,
94
+ ): string => {
95
+ const lines = idleSubAgents.map(
96
+ (subAgent) =>
97
+ `- ${subAgent.label}: no output for ${formatMinutes(subAgent.silentSeconds)}`,
98
+ );
99
+ const parts: string[] = [];
100
+ if (header !== null) {
101
+ parts.push(header);
102
+ }
103
+ parts.push(...lines);
104
+ if (footer !== null) {
105
+ parts.push(footer);
106
+ }
107
+ return parts.join('\n');
108
+ };
109
+
110
+ private composeLongRunningSection = (
111
+ longRunningSubAgents: SubAgentActivity[],
112
+ header: string | null,
113
+ footer: string | null,
114
+ ): string => {
115
+ const lines = longRunningSubAgents.map(
116
+ (subAgent) =>
117
+ `- ${subAgent.label}: running for ${formatMinutes(subAgent.runningSeconds)}`,
118
+ );
119
+ const parts: string[] = [];
120
+ if (header !== null) {
121
+ parts.push(header);
50
122
  }
51
- sections.push(...lines);
52
- if (this.templates.subAgentMessageFooter !== null) {
53
- sections.push(this.templates.subAgentMessageFooter);
123
+ parts.push(...lines);
124
+ if (footer !== null) {
125
+ parts.push(footer);
54
126
  }
55
- return withReminderSentinel(sections.join('\n'));
127
+ return parts.join('\n');
56
128
  };
57
129
  }
@@ -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
+ }