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.
- package/CHANGELOG.md +14 -0
- package/README.md +11 -7
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +38 -12
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +7 -2
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
- package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +44 -11
- package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
- package/bin/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.js +128 -0
- package/bin/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.js.map +1 -0
- package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +27 -7
- package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +65 -17
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
- package/bin/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +29 -8
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +14 -4
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +13 -0
- package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +81 -28
- package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +91 -19
- package/src/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.test.ts +251 -0
- package/src/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.ts +121 -0
- package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +91 -15
- package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +54 -11
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +252 -9
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +111 -20
- package/src/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.ts +20 -0
- package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +9 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -0
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
- package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +8 -4
- package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
- package/types/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.d.ts +20 -0
- package/types/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.d.ts.map +1 -0
- package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +2 -2
- package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +7 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.d.ts +19 -0
- package/types/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +5 -1
- 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
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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:
|
|
72
|
-
|
|
73
|
-
|
|
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('
|
|
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('
|
|
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 {
|
|
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
|
-
|
|
13
|
-
|
|
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 = (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
52
|
+
|
|
53
|
+
const idleSubAgents = subAgents.filter(
|
|
54
|
+
(subAgent) =>
|
|
55
|
+
subAgent.silentSeconds >= thresholds.subAgentSilentThresholdSeconds,
|
|
56
|
+
);
|
|
57
|
+
const longRunningSubAgents = subAgents.filter(
|
|
42
58
|
(subAgent) =>
|
|
43
|
-
|
|
44
|
-
subAgent.silentSeconds,
|
|
45
|
-
)}, running for ${formatMinutes(subAgent.runningSeconds)}`,
|
|
59
|
+
subAgent.runningSeconds >= thresholds.subAgentRunningThresholdSeconds,
|
|
46
60
|
);
|
|
61
|
+
|
|
47
62
|
const sections: string[] = [];
|
|
48
|
-
if (
|
|
49
|
-
sections.push(
|
|
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
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
123
|
+
parts.push(...lines);
|
|
124
|
+
if (footer !== null) {
|
|
125
|
+
parts.push(footer);
|
|
54
126
|
}
|
|
55
|
-
return
|
|
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
|
+
}
|