github-issue-tower-defence-management 1.139.1 → 1.140.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.
- package/CHANGELOG.md +7 -0
- package/README.md +6 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +43 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js +31 -0
- package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js.map +1 -0
- package/bin/adapter/repositories/FileHandoverStateRepository.js +99 -0
- package/bin/adapter/repositories/FileHandoverStateRepository.js.map +1 -0
- package/bin/adapter/repositories/NodeProcessSignalRepository.js +44 -0
- package/bin/adapter/repositories/NodeProcessSignalRepository.js.map +1 -0
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js +67 -1
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
- package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js +206 -0
- package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js.map +1 -0
- package/bin/adapter/repositories/RateLimitSnapshotRepository.js +44 -0
- package/bin/adapter/repositories/RateLimitSnapshotRepository.js.map +1 -0
- package/bin/domain/entities/ClaudeHandoverSession.js +3 -0
- package/bin/domain/entities/ClaudeHandoverSession.js.map +1 -0
- package/bin/domain/entities/TokenExhaustionHandoverState.js +3 -0
- package/bin/domain/entities/TokenExhaustionHandoverState.js.map +1 -0
- package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js +255 -0
- package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/ProcessSignalRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/ProcessSignalRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +32 -0
- package/src/adapter/entry-points/handlers/tokenExhaustionHandover.ts +79 -0
- package/src/adapter/repositories/FileHandoverStateRepository.test.ts +114 -0
- package/src/adapter/repositories/FileHandoverStateRepository.ts +76 -0
- package/src/adapter/repositories/NodeProcessSignalRepository.test.ts +82 -0
- package/src/adapter/repositories/NodeProcessSignalRepository.ts +47 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +132 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.ts +100 -0
- package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.test.ts +194 -0
- package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.ts +208 -0
- package/src/adapter/repositories/RateLimitSnapshotRepository.ts +50 -0
- package/src/domain/entities/ClaudeHandoverSession.ts +11 -0
- package/src/domain/entities/TokenExhaustionHandoverState.ts +8 -0
- package/src/domain/usecases/TokenExhaustionHandoverUseCase.test.ts +696 -0
- package/src/domain/usecases/TokenExhaustionHandoverUseCase.ts +413 -0
- package/src/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.ts +5 -0
- package/src/domain/usecases/adapter-interfaces/ProcessSignalRepository.ts +5 -0
- package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +2 -0
- package/src/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.ts +6 -0
- package/src/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.ts +22 -0
- package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +2 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/tokenExhaustionHandover.d.ts +14 -0
- package/types/adapter/entry-points/handlers/tokenExhaustionHandover.d.ts.map +1 -0
- package/types/adapter/repositories/FileHandoverStateRepository.d.ts +11 -0
- package/types/adapter/repositories/FileHandoverStateRepository.d.ts.map +1 -0
- package/types/adapter/repositories/NodeProcessSignalRepository.d.ts +8 -0
- package/types/adapter/repositories/NodeProcessSignalRepository.d.ts.map +1 -0
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +7 -1
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
- package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts +13 -0
- package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts.map +1 -0
- package/types/adapter/repositories/RateLimitSnapshotRepository.d.ts +9 -0
- package/types/adapter/repositories/RateLimitSnapshotRepository.d.ts.map +1 -0
- package/types/domain/entities/ClaudeHandoverSession.d.ts +10 -0
- package/types/domain/entities/ClaudeHandoverSession.d.ts.map +1 -0
- package/types/domain/entities/TokenExhaustionHandoverState.d.ts +8 -0
- package/types/domain/entities/TokenExhaustionHandoverState.d.ts.map +1 -0
- package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts +50 -0
- package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.d.ts +5 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/ProcessSignalRepository.d.ts +6 -0
- package/types/domain/usecases/adapter-interfaces/ProcessSignalRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +2 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.d.ts +6 -0
- package/types/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.d.ts +21 -0
- package/types/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.d.ts.map +1 -0
|
@@ -10,6 +10,7 @@ import { writeMachineStatus } from './machineStatusWriter';
|
|
|
10
10
|
import { writeTokenStatus } from './tokenStatusWriter';
|
|
11
11
|
import { writeInTmuxByHumanData } from './inTmuxByHumanDataWriter';
|
|
12
12
|
import { reconcileInTmuxByHumanSessions } from './inTmuxByHumanSessionReconciler';
|
|
13
|
+
import { handleTokenExhaustionHandover } from './tokenExhaustionHandover';
|
|
13
14
|
import { cleanStaleTmuxSessions } from './staleTmuxSessionCleaner';
|
|
14
15
|
import {
|
|
15
16
|
notifySilentTmuxSessions,
|
|
@@ -115,6 +116,12 @@ export class HandleScheduledEventUseCaseHandler {
|
|
|
115
116
|
inTmuxConsoleToken?: string;
|
|
116
117
|
inTmuxProjectOrder?: string[];
|
|
117
118
|
inTmuxLauncherCommand?: string;
|
|
119
|
+
tokenExhaustionHandoverEnabled?: boolean;
|
|
120
|
+
tokenExhaustionHandoverMessage?: string;
|
|
121
|
+
tokenExhaustionHandoverBareNameLeaderMessage?: string;
|
|
122
|
+
tokenRateLimitSnapshotBaseDir?: string;
|
|
123
|
+
tokenExhaustionGracePeriodSeconds?: number;
|
|
124
|
+
tokenExhaustionHandoverStateFilePath?: string;
|
|
118
125
|
silentNotificationEnabled?: boolean;
|
|
119
126
|
ownerCallMarker?: string;
|
|
120
127
|
subAgentOutputRootDirectory?: string;
|
|
@@ -554,6 +561,31 @@ export class HandleScheduledEventUseCaseHandler {
|
|
|
554
561
|
);
|
|
555
562
|
}
|
|
556
563
|
|
|
564
|
+
try {
|
|
565
|
+
await handleTokenExhaustionHandover({
|
|
566
|
+
enabled: mergedInput.tokenExhaustionHandoverEnabled ?? false,
|
|
567
|
+
tokenListJsonPath:
|
|
568
|
+
mergedInput.claudeCodeOauthTokenListJsonPath ?? null,
|
|
569
|
+
handoverMessage: mergedInput.tokenExhaustionHandoverMessage ?? null,
|
|
570
|
+
bareNameLeaderHandoverMessage:
|
|
571
|
+
mergedInput.tokenExhaustionHandoverBareNameLeaderMessage ?? null,
|
|
572
|
+
tokenRateLimitSnapshotBaseDir:
|
|
573
|
+
mergedInput.tokenRateLimitSnapshotBaseDir ?? null,
|
|
574
|
+
gracePeriodSeconds:
|
|
575
|
+
mergedInput.tokenExhaustionGracePeriodSeconds ?? null,
|
|
576
|
+
stateFilePath:
|
|
577
|
+
mergedInput.tokenExhaustionHandoverStateFilePath ?? null,
|
|
578
|
+
localCommandRunner: nodeLocalCommandRunner,
|
|
579
|
+
now: inTmuxNow,
|
|
580
|
+
});
|
|
581
|
+
} catch (error) {
|
|
582
|
+
console.error(
|
|
583
|
+
`Failed to handle token exhaustion handover: ${
|
|
584
|
+
error instanceof Error ? error.message : String(error)
|
|
585
|
+
}`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
557
589
|
try {
|
|
558
590
|
await reconcileInTmuxByHumanSessions({
|
|
559
591
|
inTmuxLauncherCommand: mergedInput.inTmuxLauncherCommand ?? null,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
2
|
+
import {
|
|
3
|
+
TokenExhaustionHandoverUseCase,
|
|
4
|
+
DEFAULT_TOKEN_EXHAUSTION_HANDOVER_MESSAGE,
|
|
5
|
+
DEFAULT_TOKEN_EXHAUSTION_HANDOVER_MESSAGE_BARE_NAME_LEADER,
|
|
6
|
+
DEFAULT_TOKEN_EXHAUSTION_GRACE_PERIOD_SECONDS,
|
|
7
|
+
} from '../../../domain/usecases/TokenExhaustionHandoverUseCase';
|
|
8
|
+
import { NodeTmuxSessionRepository } from '../../repositories/NodeTmuxSessionRepository';
|
|
9
|
+
import { RateLimitSnapshotRepository } from '../../repositories/RateLimitSnapshotRepository';
|
|
10
|
+
import { ProcClaudeHandoverSessionRepository } from '../../repositories/ProcClaudeHandoverSessionRepository';
|
|
11
|
+
import { NodeProcessSignalRepository } from '../../repositories/NodeProcessSignalRepository';
|
|
12
|
+
import {
|
|
13
|
+
FileHandoverStateRepository,
|
|
14
|
+
defaultHandoverStateFilePath,
|
|
15
|
+
} from '../../repositories/FileHandoverStateRepository';
|
|
16
|
+
|
|
17
|
+
export type TokenExhaustionHandoverParams = {
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
tokenListJsonPath: string | null;
|
|
20
|
+
handoverMessage?: string | null;
|
|
21
|
+
bareNameLeaderHandoverMessage?: string | null;
|
|
22
|
+
tokenRateLimitSnapshotBaseDir?: string | null;
|
|
23
|
+
gracePeriodSeconds?: number | null;
|
|
24
|
+
stateFilePath?: string | null;
|
|
25
|
+
localCommandRunner: LocalCommandRunner;
|
|
26
|
+
now: Date;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const handleTokenExhaustionHandover = async (
|
|
30
|
+
params: TokenExhaustionHandoverParams,
|
|
31
|
+
): Promise<void> => {
|
|
32
|
+
const {
|
|
33
|
+
enabled,
|
|
34
|
+
tokenListJsonPath,
|
|
35
|
+
handoverMessage,
|
|
36
|
+
bareNameLeaderHandoverMessage,
|
|
37
|
+
tokenRateLimitSnapshotBaseDir,
|
|
38
|
+
gracePeriodSeconds,
|
|
39
|
+
stateFilePath,
|
|
40
|
+
localCommandRunner,
|
|
41
|
+
now,
|
|
42
|
+
} = params;
|
|
43
|
+
|
|
44
|
+
if (tokenListJsonPath === null) {
|
|
45
|
+
console.log(
|
|
46
|
+
'Token exhaustion handover: skipped (no claudeCodeOauthTokenListJsonPath configured).',
|
|
47
|
+
);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const snapshotRepository = new RateLimitSnapshotRepository(
|
|
52
|
+
tokenListJsonPath,
|
|
53
|
+
tokenRateLimitSnapshotBaseDir ?? undefined,
|
|
54
|
+
);
|
|
55
|
+
const stateRepository = new FileHandoverStateRepository(
|
|
56
|
+
stateFilePath ?? defaultHandoverStateFilePath(),
|
|
57
|
+
);
|
|
58
|
+
const useCase = new TokenExhaustionHandoverUseCase(
|
|
59
|
+
new ProcClaudeHandoverSessionRepository(),
|
|
60
|
+
snapshotRepository,
|
|
61
|
+
new NodeTmuxSessionRepository(localCommandRunner),
|
|
62
|
+
new NodeProcessSignalRepository(),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const result = await useCase.run({
|
|
66
|
+
enabled,
|
|
67
|
+
issueUrlLeaderMessage:
|
|
68
|
+
handoverMessage ?? DEFAULT_TOKEN_EXHAUSTION_HANDOVER_MESSAGE,
|
|
69
|
+
bareNameLeaderMessage:
|
|
70
|
+
bareNameLeaderHandoverMessage ??
|
|
71
|
+
DEFAULT_TOKEN_EXHAUSTION_HANDOVER_MESSAGE_BARE_NAME_LEADER,
|
|
72
|
+
gracePeriodSeconds:
|
|
73
|
+
gracePeriodSeconds ?? DEFAULT_TOKEN_EXHAUSTION_GRACE_PERIOD_SECONDS,
|
|
74
|
+
state: stateRepository.load(),
|
|
75
|
+
now,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
stateRepository.save(result.state);
|
|
79
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
FileHandoverStateRepository,
|
|
6
|
+
defaultHandoverStateFilePath,
|
|
7
|
+
} from './FileHandoverStateRepository';
|
|
8
|
+
|
|
9
|
+
describe('FileHandoverStateRepository', () => {
|
|
10
|
+
let directory: string;
|
|
11
|
+
let filePath: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'handover-state-'));
|
|
15
|
+
filePath = path.join(directory, 'nested', 'state.json');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns an empty state when the file does not exist', () => {
|
|
23
|
+
const repository = new FileHandoverStateRepository(filePath);
|
|
24
|
+
|
|
25
|
+
expect(repository.load()).toEqual({ entries: {} });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('saves and reloads state, creating parent directories', () => {
|
|
29
|
+
const repository = new FileHandoverStateRepository(filePath);
|
|
30
|
+
const state = {
|
|
31
|
+
entries: {
|
|
32
|
+
'session-a': { signaledAtEpoch: 1700000000, pid: 4242 },
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
repository.save(state);
|
|
37
|
+
|
|
38
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
39
|
+
expect(new FileHandoverStateRepository(filePath).load()).toEqual(state);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('writes atomically via a temporary file and leaves no temp file behind', () => {
|
|
43
|
+
const repository = new FileHandoverStateRepository(filePath);
|
|
44
|
+
|
|
45
|
+
repository.save({
|
|
46
|
+
entries: { 'session-a': { signaledAtEpoch: 1, pid: 2 } },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns an empty state when the file contains invalid JSON', () => {
|
|
53
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
54
|
+
fs.writeFileSync(filePath, 'not json');
|
|
55
|
+
const repository = new FileHandoverStateRepository(filePath);
|
|
56
|
+
|
|
57
|
+
expect(repository.load()).toEqual({ entries: {} });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('drops entries with malformed values while keeping valid ones', () => {
|
|
61
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
62
|
+
fs.writeFileSync(
|
|
63
|
+
filePath,
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
entries: {
|
|
66
|
+
valid: { signaledAtEpoch: 5, pid: 6 },
|
|
67
|
+
missingPid: { signaledAtEpoch: 5 },
|
|
68
|
+
notAnObject: 7,
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
const repository = new FileHandoverStateRepository(filePath);
|
|
73
|
+
|
|
74
|
+
expect(repository.load()).toEqual({
|
|
75
|
+
entries: { valid: { signaledAtEpoch: 5, pid: 6 } },
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('defaultHandoverStateFilePath', () => {
|
|
81
|
+
const originalXdgCacheHome = process.env.XDG_CACHE_HOME;
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
if (originalXdgCacheHome === undefined) {
|
|
85
|
+
delete process.env.XDG_CACHE_HOME;
|
|
86
|
+
} else {
|
|
87
|
+
process.env.XDG_CACHE_HOME = originalXdgCacheHome;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('uses a TDPM-native filename distinct from the standalone monitor state file', () => {
|
|
92
|
+
process.env.XDG_CACHE_HOME = '/custom/cache';
|
|
93
|
+
|
|
94
|
+
expect(defaultHandoverStateFilePath()).toBe(
|
|
95
|
+
'/custom/cache/tdpm/token-exhaustion-handover-state-tdpm-native.json',
|
|
96
|
+
);
|
|
97
|
+
expect(defaultHandoverStateFilePath()).not.toBe(
|
|
98
|
+
'/custom/cache/tdpm/token-exhaustion-handover-state.json',
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('falls back to ~/.cache when XDG_CACHE_HOME is unset', () => {
|
|
103
|
+
delete process.env.XDG_CACHE_HOME;
|
|
104
|
+
|
|
105
|
+
expect(defaultHandoverStateFilePath()).toBe(
|
|
106
|
+
path.join(
|
|
107
|
+
os.homedir(),
|
|
108
|
+
'.cache',
|
|
109
|
+
'tdpm',
|
|
110
|
+
'token-exhaustion-handover-state-tdpm-native.json',
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
TokenExhaustionHandoverState,
|
|
6
|
+
TokenExhaustionHandoverStateEntry,
|
|
7
|
+
} from '../../domain/entities/TokenExhaustionHandoverState';
|
|
8
|
+
import { TokenExhaustionHandoverStateRepository } from '../../domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository';
|
|
9
|
+
|
|
10
|
+
export const defaultHandoverStateFilePath = (): string => {
|
|
11
|
+
const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
|
|
12
|
+
return path.join(
|
|
13
|
+
base,
|
|
14
|
+
'tdpm',
|
|
15
|
+
'token-exhaustion-handover-state-tdpm-native.json',
|
|
16
|
+
);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
20
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
|
|
22
|
+
export class FileHandoverStateRepository implements TokenExhaustionHandoverStateRepository {
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly filePath: string = defaultHandoverStateFilePath(),
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
load = (): TokenExhaustionHandoverState => {
|
|
28
|
+
let raw: string;
|
|
29
|
+
try {
|
|
30
|
+
raw = fs.readFileSync(this.filePath, 'utf8');
|
|
31
|
+
} catch {
|
|
32
|
+
return { entries: {} };
|
|
33
|
+
}
|
|
34
|
+
let parsed: unknown;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(raw);
|
|
37
|
+
} catch {
|
|
38
|
+
return { entries: {} };
|
|
39
|
+
}
|
|
40
|
+
return { entries: this.parseEntries(parsed) };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
save = (state: TokenExhaustionHandoverState): void => {
|
|
44
|
+
const directory = path.dirname(this.filePath);
|
|
45
|
+
if (!fs.existsSync(directory)) {
|
|
46
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
const temporaryPath = `${this.filePath}.tmp`;
|
|
49
|
+
fs.writeFileSync(temporaryPath, JSON.stringify(state));
|
|
50
|
+
fs.renameSync(temporaryPath, this.filePath);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
private parseEntries = (
|
|
54
|
+
parsed: unknown,
|
|
55
|
+
): Record<string, TokenExhaustionHandoverStateEntry> => {
|
|
56
|
+
const entries: Record<string, TokenExhaustionHandoverStateEntry> = {};
|
|
57
|
+
if (!isRecord(parsed)) {
|
|
58
|
+
return entries;
|
|
59
|
+
}
|
|
60
|
+
const stored = parsed.entries;
|
|
61
|
+
if (!isRecord(stored)) {
|
|
62
|
+
return entries;
|
|
63
|
+
}
|
|
64
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
65
|
+
if (!isRecord(value)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const signaledAtEpoch = value.signaledAtEpoch;
|
|
69
|
+
const pid = value.pid;
|
|
70
|
+
if (typeof signaledAtEpoch === 'number' && typeof pid === 'number') {
|
|
71
|
+
entries[key] = { signaledAtEpoch, pid };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return entries;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { NodeProcessSignalRepository } from './NodeProcessSignalRepository';
|
|
2
|
+
|
|
3
|
+
const errnoError = (message: string, code: string): NodeJS.ErrnoException => {
|
|
4
|
+
const error: NodeJS.ErrnoException = new Error(message);
|
|
5
|
+
error.code = code;
|
|
6
|
+
return error;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
describe('NodeProcessSignalRepository', () => {
|
|
10
|
+
let repository: NodeProcessSignalRepository;
|
|
11
|
+
let killSpy: jest.SpyInstance;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
repository = new NodeProcessSignalRepository();
|
|
15
|
+
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
jest.restoreAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('isProcessAlive', () => {
|
|
23
|
+
it('returns true for the current process', () => {
|
|
24
|
+
expect(repository.isProcessAlive(process.pid)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns false for a pid that does not exist', () => {
|
|
28
|
+
killSpy = jest.spyOn(process, 'kill').mockImplementation(() => {
|
|
29
|
+
throw errnoError('no such process', 'ESRCH');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(repository.isProcessAlive(999999)).toBe(false);
|
|
33
|
+
expect(killSpy).toHaveBeenCalledWith(999999, 0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns true when signalling is not permitted', () => {
|
|
37
|
+
jest.spyOn(process, 'kill').mockImplementation(() => {
|
|
38
|
+
throw errnoError('operation not permitted', 'EPERM');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(repository.isProcessAlive(1)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('terminateProcess', () => {
|
|
46
|
+
it('sends SIGTERM to the pid', () => {
|
|
47
|
+
killSpy = jest.spyOn(process, 'kill').mockReturnValue(true);
|
|
48
|
+
|
|
49
|
+
repository.terminateProcess(4242);
|
|
50
|
+
|
|
51
|
+
expect(killSpy).toHaveBeenCalledWith(4242, 'SIGTERM');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does not throw or log when the process already exited', () => {
|
|
55
|
+
jest.spyOn(process, 'kill').mockImplementation(() => {
|
|
56
|
+
throw errnoError('no such process', 'ESRCH');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(() => repository.terminateProcess(4242)).not.toThrow();
|
|
60
|
+
expect(console.error).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('killProcess', () => {
|
|
65
|
+
it('sends SIGKILL to the pid', () => {
|
|
66
|
+
killSpy = jest.spyOn(process, 'kill').mockReturnValue(true);
|
|
67
|
+
|
|
68
|
+
repository.killProcess(4242);
|
|
69
|
+
|
|
70
|
+
expect(killSpy).toHaveBeenCalledWith(4242, 'SIGKILL');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('logs an unexpected signalling error without throwing', () => {
|
|
74
|
+
jest.spyOn(process, 'kill').mockImplementation(() => {
|
|
75
|
+
throw errnoError('operation not permitted', 'EPERM');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(() => repository.killProcess(4242)).not.toThrow();
|
|
79
|
+
expect(console.error).toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ProcessSignalRepository } from '../../domain/usecases/adapter-interfaces/ProcessSignalRepository';
|
|
2
|
+
|
|
3
|
+
const errorCode = (error: unknown): string | null => {
|
|
4
|
+
if (
|
|
5
|
+
error !== null &&
|
|
6
|
+
typeof error === 'object' &&
|
|
7
|
+
'code' in error &&
|
|
8
|
+
typeof error.code === 'string'
|
|
9
|
+
) {
|
|
10
|
+
return error.code;
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export class NodeProcessSignalRepository implements ProcessSignalRepository {
|
|
16
|
+
isProcessAlive = (pid: number): boolean => {
|
|
17
|
+
try {
|
|
18
|
+
process.kill(pid, 0);
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return errorCode(error) === 'EPERM';
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
terminateProcess = (pid: number): void => {
|
|
26
|
+
this.signal(pid, 'SIGTERM');
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
killProcess = (pid: number): void => {
|
|
30
|
+
this.signal(pid, 'SIGKILL');
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
private signal = (pid: number, signal: NodeJS.Signals): void => {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, signal);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (errorCode(error) === 'ESRCH') {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
console.error(
|
|
41
|
+
`Failed to send ${signal} to pid ${pid}: ${
|
|
42
|
+
error instanceof Error ? error.message : String(error)
|
|
43
|
+
}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -294,4 +294,136 @@ describe('NodeTmuxSessionRepository', () => {
|
|
|
294
294
|
]);
|
|
295
295
|
});
|
|
296
296
|
});
|
|
297
|
+
|
|
298
|
+
describe('sendKeys', () => {
|
|
299
|
+
it('sends the literal text then Enter and stops when the composer is empty', async () => {
|
|
300
|
+
const runner = createMockRunner();
|
|
301
|
+
runner.runCommand
|
|
302
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
|
|
303
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
|
|
304
|
+
.mockResolvedValueOnce({
|
|
305
|
+
stdout: 'idle pane content\n> ',
|
|
306
|
+
stderr: '',
|
|
307
|
+
exitCode: 0,
|
|
308
|
+
});
|
|
309
|
+
const repository = new NodeTmuxSessionRepository(runner, '/proc', 0);
|
|
310
|
+
|
|
311
|
+
await repository.sendKeys('session-a', 'checkpoint now');
|
|
312
|
+
|
|
313
|
+
expect(runner.runCommand.mock.calls[0][1]).toEqual([
|
|
314
|
+
'send-keys',
|
|
315
|
+
'-t',
|
|
316
|
+
'session-a',
|
|
317
|
+
'-l',
|
|
318
|
+
'checkpoint now',
|
|
319
|
+
]);
|
|
320
|
+
expect(runner.runCommand.mock.calls[1][1]).toEqual([
|
|
321
|
+
'send-keys',
|
|
322
|
+
'-t',
|
|
323
|
+
'session-a',
|
|
324
|
+
'Enter',
|
|
325
|
+
]);
|
|
326
|
+
expect(runner.runCommand.mock.calls[2][1]).toEqual([
|
|
327
|
+
'capture-pane',
|
|
328
|
+
'-p',
|
|
329
|
+
'-t',
|
|
330
|
+
'session-a',
|
|
331
|
+
]);
|
|
332
|
+
expect(runner.runCommand.mock.calls).toHaveLength(3);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('re-sends Enter when the message is still visible in the composer', async () => {
|
|
336
|
+
const runner = createMockRunner();
|
|
337
|
+
runner.runCommand
|
|
338
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
|
|
339
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
|
|
340
|
+
.mockResolvedValueOnce({
|
|
341
|
+
stdout: '> checkpoint now',
|
|
342
|
+
stderr: '',
|
|
343
|
+
exitCode: 0,
|
|
344
|
+
})
|
|
345
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
346
|
+
const repository = new NodeTmuxSessionRepository(runner, '/proc', 0);
|
|
347
|
+
|
|
348
|
+
await repository.sendKeys('session-a', 'checkpoint now');
|
|
349
|
+
|
|
350
|
+
expect(runner.runCommand.mock.calls).toHaveLength(4);
|
|
351
|
+
expect(runner.runCommand.mock.calls[3][1]).toEqual([
|
|
352
|
+
'send-keys',
|
|
353
|
+
'-t',
|
|
354
|
+
'session-a',
|
|
355
|
+
'Enter',
|
|
356
|
+
]);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('throws when sending the literal text fails', async () => {
|
|
360
|
+
const runner = createMockRunner();
|
|
361
|
+
runner.runCommand.mockResolvedValue({
|
|
362
|
+
stdout: '',
|
|
363
|
+
stderr: 'no session',
|
|
364
|
+
exitCode: 1,
|
|
365
|
+
});
|
|
366
|
+
const repository = new NodeTmuxSessionRepository(runner, '/proc', 0);
|
|
367
|
+
|
|
368
|
+
await expect(repository.sendKeys('session-a', 'text')).rejects.toThrow(
|
|
369
|
+
'Failed to send keys to tmux session "session-a"',
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe('launchBareNameLeaderSession', () => {
|
|
375
|
+
it('creates a detached tmux session running cl for the bare name', async () => {
|
|
376
|
+
const runner = createMockRunner();
|
|
377
|
+
runner.runCommand.mockResolvedValue({
|
|
378
|
+
stdout: '',
|
|
379
|
+
stderr: '',
|
|
380
|
+
exitCode: 0,
|
|
381
|
+
});
|
|
382
|
+
const repository = new NodeTmuxSessionRepository(runner);
|
|
383
|
+
|
|
384
|
+
await repository.launchBareNameLeaderSession('app');
|
|
385
|
+
|
|
386
|
+
expect(runner.runCommand.mock.calls[0][0]).toBe('tmux');
|
|
387
|
+
expect(runner.runCommand.mock.calls[0][1]).toEqual([
|
|
388
|
+
'new-session',
|
|
389
|
+
'-d',
|
|
390
|
+
'-s',
|
|
391
|
+
'app',
|
|
392
|
+
'bash',
|
|
393
|
+
'-lc',
|
|
394
|
+
"cl 'app'; exec /bin/bash",
|
|
395
|
+
]);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('normalizes dots and colons in the session name', async () => {
|
|
399
|
+
const runner = createMockRunner();
|
|
400
|
+
runner.runCommand.mockResolvedValue({
|
|
401
|
+
stdout: '',
|
|
402
|
+
stderr: '',
|
|
403
|
+
exitCode: 0,
|
|
404
|
+
});
|
|
405
|
+
const repository = new NodeTmuxSessionRepository(runner);
|
|
406
|
+
|
|
407
|
+
await repository.launchBareNameLeaderSession('tdpm-cli.v2');
|
|
408
|
+
|
|
409
|
+
expect(runner.runCommand.mock.calls[0][1][3]).toBe('tdpm-cli_v2');
|
|
410
|
+
expect(runner.runCommand.mock.calls[0][1][6]).toBe(
|
|
411
|
+
"cl 'tdpm-cli.v2'; exec /bin/bash",
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('throws when tmux fails to create the session', async () => {
|
|
416
|
+
const runner = createMockRunner();
|
|
417
|
+
runner.runCommand.mockResolvedValue({
|
|
418
|
+
stdout: '',
|
|
419
|
+
stderr: 'duplicate session',
|
|
420
|
+
exitCode: 1,
|
|
421
|
+
});
|
|
422
|
+
const repository = new NodeTmuxSessionRepository(runner);
|
|
423
|
+
|
|
424
|
+
await expect(
|
|
425
|
+
repository.launchBareNameLeaderSession('app'),
|
|
426
|
+
).rejects.toThrow('Failed to relaunch bare-name leader session "app"');
|
|
427
|
+
});
|
|
428
|
+
});
|
|
297
429
|
});
|