github-issue-tower-defence-management 1.95.0 → 1.97.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/.github/workflows/console-ui.yml +17 -0
- package/CHANGELOG.md +14 -0
- package/README.md +20 -0
- package/bin/adapter/entry-points/cli/index.js +21 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-BeJzGnfH.js → index-B3F4E8LD.js} +33 -33
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +18 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +77 -0
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -0
- package/bin/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.js +19 -0
- package/bin/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.js.map +1 -0
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js +44 -0
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -0
- package/bin/adapter/repositories/ProcClaudeLiveSessionRepository.js +139 -0
- package/bin/adapter/repositories/ProcClaudeLiveSessionRepository.js.map +1 -0
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +59 -0
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/TmuxSessionRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/TmuxSessionRepository.js.map +1 -0
- package/bin/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.js +41 -0
- package/bin/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.js.map +1 -0
- package/package.json +4 -1
- package/src/adapter/entry-points/cli/index.ts +38 -0
- package/src/adapter/entry-points/console/ui/biome.json +7 -1
- package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +93 -0
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +380 -0
- package/src/adapter/entry-points/console/ui/e2e/playwright.config.ts +30 -0
- package/src/adapter/entry-points/console/ui/e2e/tsconfig.json +18 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +2 -4
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +5 -6
- package/src/adapter/entry-points/console/ui-dist/assets/{index-BeJzGnfH.js → index-B3F4E8LD.js} +33 -33
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +17 -0
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +268 -0
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +116 -0
- package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.test.ts +123 -0
- package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.ts +29 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +100 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.ts +53 -0
- package/src/adapter/repositories/ProcClaudeLiveSessionRepository.test.ts +146 -0
- package/src/adapter/repositories/ProcClaudeLiveSessionRepository.ts +119 -0
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +147 -0
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +100 -0
- package/src/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.ts +8 -0
- package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +9 -0
- package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +186 -0
- package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.ts +75 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts +20 -0
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.d.ts +10 -0
- package/types/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.d.ts.map +1 -0
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +10 -0
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -0
- package/types/adapter/repositories/ProcClaudeLiveSessionRepository.d.ts +11 -0
- package/types/adapter/repositories/ProcClaudeLiveSessionRepository.d.ts.map +1 -0
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +23 -0
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.d.ts +8 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +6 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -0
- package/types/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.d.ts +19 -0
- package/types/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.d.ts.map +1 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
2
|
+
import { TmuxSessionRepository } from '../../domain/usecases/adapter-interfaces/TmuxSessionRepository';
|
|
3
|
+
|
|
4
|
+
export class NodeTmuxSessionRepository implements TmuxSessionRepository {
|
|
5
|
+
constructor(private readonly localCommandRunner: LocalCommandRunner) {}
|
|
6
|
+
|
|
7
|
+
listLiveSessionNames = async (): Promise<string[]> => {
|
|
8
|
+
const { stdout, exitCode } = await this.localCommandRunner.runCommand(
|
|
9
|
+
'tmux',
|
|
10
|
+
['list-sessions', '-F', '#{session_name}'],
|
|
11
|
+
);
|
|
12
|
+
if (exitCode !== 0) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
return stdout
|
|
16
|
+
.split('\n')
|
|
17
|
+
.map((line) => line.trim())
|
|
18
|
+
.filter((line) => line.length > 0);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
listInteractiveProcessCommandLines = async (): Promise<string[]> => {
|
|
22
|
+
const { stdout, exitCode } = await this.localCommandRunner.runCommand(
|
|
23
|
+
'ps',
|
|
24
|
+
['-eo', 'args='],
|
|
25
|
+
);
|
|
26
|
+
if (exitCode !== 0) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return stdout
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map((line) => line.trim())
|
|
32
|
+
.filter((line) => line.length > 0);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
launchDetachedSession = async (
|
|
36
|
+
sessionName: string,
|
|
37
|
+
launcherCommand: string,
|
|
38
|
+
issueUrl: string,
|
|
39
|
+
): Promise<void> => {
|
|
40
|
+
await this.localCommandRunner.runCommand('tmux', [
|
|
41
|
+
'new-session',
|
|
42
|
+
'-d',
|
|
43
|
+
'-s',
|
|
44
|
+
sessionName,
|
|
45
|
+
'sh',
|
|
46
|
+
'-lc',
|
|
47
|
+
`exec "$1" "$2"`,
|
|
48
|
+
'sh',
|
|
49
|
+
launcherCommand,
|
|
50
|
+
issueUrl,
|
|
51
|
+
]);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { ProcClaudeLiveSessionRepository } from './ProcClaudeLiveSessionRepository';
|
|
5
|
+
|
|
6
|
+
type FakeProcess = {
|
|
7
|
+
pid: number;
|
|
8
|
+
cmdline: string;
|
|
9
|
+
environ: Record<string, string>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe('ProcClaudeLiveSessionRepository', () => {
|
|
13
|
+
let procDirectory: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
procDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-proc-'));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
fs.rmSync(procDirectory, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const writeProcess = (fakeProcess: FakeProcess): void => {
|
|
24
|
+
const processDirectory = path.join(procDirectory, String(fakeProcess.pid));
|
|
25
|
+
fs.mkdirSync(processDirectory, { recursive: true });
|
|
26
|
+
fs.writeFileSync(
|
|
27
|
+
path.join(processDirectory, 'cmdline'),
|
|
28
|
+
fakeProcess.cmdline,
|
|
29
|
+
);
|
|
30
|
+
const environBuffer = Object.entries(fakeProcess.environ)
|
|
31
|
+
.map(([key, value]) => `${key}=${value}\0`)
|
|
32
|
+
.join('');
|
|
33
|
+
fs.writeFileSync(path.join(processDirectory, 'environ'), environBuffer);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const writeNonNumericEntry = (name: string): void => {
|
|
37
|
+
fs.mkdirSync(path.join(procDirectory, name), { recursive: true });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
it('reads token and session id from a claude process environ', () => {
|
|
41
|
+
writeProcess({
|
|
42
|
+
pid: 101,
|
|
43
|
+
cmdline: '/home/user/.local/share/claude/cli.js\0--print\0',
|
|
44
|
+
environ: {
|
|
45
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-a',
|
|
46
|
+
CLAUDE_CODE_SESSION_ID: 'session-a',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const repository = new ProcClaudeLiveSessionRepository(procDirectory);
|
|
51
|
+
|
|
52
|
+
expect(repository.listLiveSessions()).toEqual([
|
|
53
|
+
{ token: 'token-a', sessionId: 'session-a' },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('identifies a claude process by the claude executable name', () => {
|
|
58
|
+
writeProcess({
|
|
59
|
+
pid: 102,
|
|
60
|
+
cmdline: '/usr/local/bin/claude\0',
|
|
61
|
+
environ: {
|
|
62
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-b',
|
|
63
|
+
CLAUDE_CODE_SESSION_ID: 'session-b',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const repository = new ProcClaudeLiveSessionRepository(procDirectory);
|
|
68
|
+
|
|
69
|
+
expect(repository.listLiveSessions()).toEqual([
|
|
70
|
+
{ token: 'token-b', sessionId: 'session-b' },
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('ignores a process without an oauth token (for example an api-key session)', () => {
|
|
75
|
+
writeProcess({
|
|
76
|
+
pid: 103,
|
|
77
|
+
cmdline: '/usr/local/bin/claude\0',
|
|
78
|
+
environ: {
|
|
79
|
+
CLAUDE_CODE_SESSION_ID: 'session-c',
|
|
80
|
+
ANTHROPIC_API_KEY: 'api-key',
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const repository = new ProcClaudeLiveSessionRepository(procDirectory);
|
|
85
|
+
|
|
86
|
+
expect(repository.listLiveSessions()).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('ignores a non-claude process that happens to carry the token', () => {
|
|
90
|
+
writeProcess({
|
|
91
|
+
pid: 104,
|
|
92
|
+
cmdline: '/usr/bin/bash\0',
|
|
93
|
+
environ: {
|
|
94
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-d',
|
|
95
|
+
CLAUDE_CODE_SESSION_ID: 'session-d',
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const repository = new ProcClaudeLiveSessionRepository(procDirectory);
|
|
100
|
+
|
|
101
|
+
expect(repository.listLiveSessions()).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns one entry per child process so the use case can dedupe by session id', () => {
|
|
105
|
+
writeProcess({
|
|
106
|
+
pid: 105,
|
|
107
|
+
cmdline: '/home/user/.local/share/claude/cli.js\0',
|
|
108
|
+
environ: {
|
|
109
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-e',
|
|
110
|
+
CLAUDE_CODE_SESSION_ID: 'session-e',
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
writeProcess({
|
|
114
|
+
pid: 106,
|
|
115
|
+
cmdline: '/home/user/.local/share/claude/cli.js\0',
|
|
116
|
+
environ: {
|
|
117
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-e',
|
|
118
|
+
CLAUDE_CODE_SESSION_ID: 'session-e',
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const repository = new ProcClaudeLiveSessionRepository(procDirectory);
|
|
123
|
+
|
|
124
|
+
expect(repository.listLiveSessions()).toEqual([
|
|
125
|
+
{ token: 'token-e', sessionId: 'session-e' },
|
|
126
|
+
{ token: 'token-e', sessionId: 'session-e' },
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('skips non-numeric proc entries and unreadable processes', () => {
|
|
131
|
+
writeNonNumericEntry('cpuinfo');
|
|
132
|
+
fs.mkdirSync(path.join(procDirectory, '107'), { recursive: true });
|
|
133
|
+
|
|
134
|
+
const repository = new ProcClaudeLiveSessionRepository(procDirectory);
|
|
135
|
+
|
|
136
|
+
expect(repository.listLiveSessions()).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns an empty list when the proc directory does not exist', () => {
|
|
140
|
+
const repository = new ProcClaudeLiveSessionRepository(
|
|
141
|
+
path.join(procDirectory, 'missing'),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(repository.listLiveSessions()).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import {
|
|
4
|
+
ClaudeLiveSession,
|
|
5
|
+
ClaudeLiveSessionRepository,
|
|
6
|
+
} from '../../domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PROC_DIRECTORY = '/proc';
|
|
9
|
+
const OAUTH_TOKEN_ENVIRON_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
|
|
10
|
+
const SESSION_ID_ENVIRON_KEY = 'CLAUDE_CODE_SESSION_ID';
|
|
11
|
+
|
|
12
|
+
const isClaudeProcessCommand = (command: string): boolean => {
|
|
13
|
+
if (command.length === 0) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (command.includes('.local/share/claude')) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
const executableName = path.basename(command.split('\0')[0] ?? '');
|
|
20
|
+
return executableName === 'claude';
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class ProcClaudeLiveSessionRepository implements ClaudeLiveSessionRepository {
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly procDirectory: string = DEFAULT_PROC_DIRECTORY,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
listLiveSessions = (): ClaudeLiveSession[] => {
|
|
29
|
+
const liveSessions: ClaudeLiveSession[] = [];
|
|
30
|
+
for (const pidDirectory of this.listProcessIdDirectories()) {
|
|
31
|
+
const liveSession = this.readLiveSession(pidDirectory);
|
|
32
|
+
if (liveSession !== null) {
|
|
33
|
+
liveSessions.push(liveSession);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return liveSessions;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
private listProcessIdDirectories = (): string[] => {
|
|
40
|
+
let entries: string[];
|
|
41
|
+
try {
|
|
42
|
+
entries = fs.readdirSync(this.procDirectory);
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
return entries.filter((entry) => /^\d+$/.test(entry));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
private readLiveSession = (
|
|
50
|
+
processIdDirectory: string,
|
|
51
|
+
): ClaudeLiveSession | null => {
|
|
52
|
+
const environ = this.readEnviron(processIdDirectory);
|
|
53
|
+
if (environ === null) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const token = environ.get(OAUTH_TOKEN_ENVIRON_KEY);
|
|
57
|
+
const sessionId = environ.get(SESSION_ID_ENVIRON_KEY);
|
|
58
|
+
if (
|
|
59
|
+
token === undefined ||
|
|
60
|
+
token.length === 0 ||
|
|
61
|
+
sessionId === undefined ||
|
|
62
|
+
sessionId.length === 0
|
|
63
|
+
) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
if (!this.isClaudeProcess(processIdDirectory)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return { token, sessionId };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
private isClaudeProcess = (processIdDirectory: string): boolean => {
|
|
73
|
+
const basePath = path.join(this.procDirectory, processIdDirectory);
|
|
74
|
+
try {
|
|
75
|
+
const cmdline = fs.readFileSync(path.join(basePath, 'cmdline'), 'utf8');
|
|
76
|
+
if (isClaudeProcessCommand(cmdline)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const exe = fs.readlinkSync(path.join(basePath, 'exe'));
|
|
84
|
+
return isClaudeProcessCommand(exe);
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
private readEnviron = (
|
|
91
|
+
processIdDirectory: string,
|
|
92
|
+
): Map<string, string> | null => {
|
|
93
|
+
const environPath = path.join(
|
|
94
|
+
this.procDirectory,
|
|
95
|
+
processIdDirectory,
|
|
96
|
+
'environ',
|
|
97
|
+
);
|
|
98
|
+
let raw: string;
|
|
99
|
+
try {
|
|
100
|
+
raw = fs.readFileSync(environPath, 'utf8');
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const environ = new Map<string, string>();
|
|
105
|
+
for (const entry of raw.split('\0')) {
|
|
106
|
+
if (entry.length === 0) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const separatorIndex = entry.indexOf('=');
|
|
110
|
+
if (separatorIndex <= 0) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const key = entry.slice(0, separatorIndex);
|
|
114
|
+
const value = entry.slice(separatorIndex + 1);
|
|
115
|
+
environ.set(key, value);
|
|
116
|
+
}
|
|
117
|
+
return environ;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { ClaudeLiveSession } from './adapter-interfaces/ClaudeLiveSessionRepository';
|
|
2
|
+
import { LiveSessionOauthTokenSelectUseCase } from './LiveSessionOauthTokenSelectUseCase';
|
|
3
|
+
import {
|
|
4
|
+
OauthTokenCandidate,
|
|
5
|
+
OauthTokenWindowSnapshot,
|
|
6
|
+
} from './OauthTokenSelectUseCase';
|
|
7
|
+
|
|
8
|
+
const NOW = 1_000_000;
|
|
9
|
+
const HOUR = 3600;
|
|
10
|
+
const DAY = 86400;
|
|
11
|
+
|
|
12
|
+
const snapshot = (
|
|
13
|
+
overrides: Partial<OauthTokenWindowSnapshot>,
|
|
14
|
+
): OauthTokenWindowSnapshot => ({
|
|
15
|
+
fiveHourUtilization: 0,
|
|
16
|
+
fiveHourReset: NOW + HOUR,
|
|
17
|
+
sevenDayUtilization: 0,
|
|
18
|
+
sevenDayReset: NOW + DAY,
|
|
19
|
+
...overrides,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const candidate = (
|
|
23
|
+
name: string,
|
|
24
|
+
snapshotValue: OauthTokenWindowSnapshot | null,
|
|
25
|
+
): OauthTokenCandidate => ({
|
|
26
|
+
name,
|
|
27
|
+
token: `fake-token-${name}`,
|
|
28
|
+
snapshot: snapshotValue,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const session = (name: string, sessionId: string): ClaudeLiveSession => ({
|
|
32
|
+
token: `fake-token-${name}`,
|
|
33
|
+
sessionId,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('LiveSessionOauthTokenSelectUseCase', () => {
|
|
37
|
+
const useCase = new LiveSessionOauthTokenSelectUseCase();
|
|
38
|
+
|
|
39
|
+
it('prefers the eligible token with zero live sessions', () => {
|
|
40
|
+
const result = useCase.run(
|
|
41
|
+
[candidate('busy', snapshot({})), candidate('idle', snapshot({}))],
|
|
42
|
+
[session('busy', 'session-a')],
|
|
43
|
+
NOW,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(result.selected?.name).toBe('idle');
|
|
47
|
+
expect(result.selected?.token).toBe('fake-token-idle');
|
|
48
|
+
const idle = result.metrics.find((m) => m.name === 'idle');
|
|
49
|
+
expect(idle?.liveSessionCount).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('breaks an occupancy tie by the soonest 7d reset', () => {
|
|
53
|
+
const result = useCase.run(
|
|
54
|
+
[
|
|
55
|
+
candidate('far', snapshot({ sevenDayReset: NOW + 6 * DAY })),
|
|
56
|
+
candidate('soon', snapshot({ sevenDayReset: NOW + 2 * DAY })),
|
|
57
|
+
],
|
|
58
|
+
[session('far', 'session-a'), session('soon', 'session-b')],
|
|
59
|
+
NOW,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(result.selected?.name).toBe('soon');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('prefers fewer live sessions over a sooner 7d reset', () => {
|
|
66
|
+
const result = useCase.run(
|
|
67
|
+
[
|
|
68
|
+
candidate('soonButBusy', snapshot({ sevenDayReset: NOW + 2 * DAY })),
|
|
69
|
+
candidate('farButIdle', snapshot({ sevenDayReset: NOW + 6 * DAY })),
|
|
70
|
+
],
|
|
71
|
+
[
|
|
72
|
+
session('soonButBusy', 'session-a'),
|
|
73
|
+
session('soonButBusy', 'session-b'),
|
|
74
|
+
],
|
|
75
|
+
NOW,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(result.selected?.name).toBe('farButIdle');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('excludes a rate-limit-ineligible token even when it has no live sessions', () => {
|
|
82
|
+
const result = useCase.run(
|
|
83
|
+
[
|
|
84
|
+
candidate('idleButBlocked', snapshot({ fiveHourUtilization: 0.9 })),
|
|
85
|
+
candidate('busyButFree', snapshot({})),
|
|
86
|
+
],
|
|
87
|
+
[session('busyButFree', 'session-a')],
|
|
88
|
+
NOW,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(result.selected?.name).toBe('busyButFree');
|
|
92
|
+
const blocked = result.metrics.find((m) => m.name === 'idleButBlocked');
|
|
93
|
+
expect(blocked?.eligible).toBe(false);
|
|
94
|
+
expect(blocked?.liveSessionCount).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('counts distinct session ids and dedupes child processes sharing one session', () => {
|
|
98
|
+
const result = useCase.run(
|
|
99
|
+
[
|
|
100
|
+
candidate('oneSession', snapshot({ sevenDayReset: NOW + 2 * DAY })),
|
|
101
|
+
candidate('twoSessions', snapshot({ sevenDayReset: NOW + 6 * DAY })),
|
|
102
|
+
],
|
|
103
|
+
[
|
|
104
|
+
session('oneSession', 'session-a'),
|
|
105
|
+
session('oneSession', 'session-a'),
|
|
106
|
+
session('oneSession', 'session-a'),
|
|
107
|
+
session('twoSessions', 'session-b'),
|
|
108
|
+
session('twoSessions', 'session-c'),
|
|
109
|
+
],
|
|
110
|
+
NOW,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(result.selected?.name).toBe('oneSession');
|
|
114
|
+
const oneSession = result.metrics.find((m) => m.name === 'oneSession');
|
|
115
|
+
const twoSessions = result.metrics.find((m) => m.name === 'twoSessions');
|
|
116
|
+
expect(oneSession?.liveSessionCount).toBe(1);
|
|
117
|
+
expect(twoSessions?.liveSessionCount).toBe(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('returns null selection when no token passes the rate-limit filter', () => {
|
|
121
|
+
const result = useCase.run(
|
|
122
|
+
[candidate('blocked', snapshot({ fiveHourUtilization: 0.9 }))],
|
|
123
|
+
[],
|
|
124
|
+
NOW,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(result.selected).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('returns null selection for an empty candidate list', () => {
|
|
131
|
+
const result = useCase.run([], [], NOW);
|
|
132
|
+
|
|
133
|
+
expect(result.selected).toBeNull();
|
|
134
|
+
expect(result.metrics).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('reports a zero live session count for tokens with no matching process', () => {
|
|
138
|
+
const result = useCase.run(
|
|
139
|
+
[candidate('lonely', snapshot({}))],
|
|
140
|
+
[session('other', 'session-x')],
|
|
141
|
+
NOW,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const lonely = result.metrics.find((m) => m.name === 'lonely');
|
|
145
|
+
expect(lonely?.liveSessionCount).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { ClaudeLiveSession } from './adapter-interfaces/ClaudeLiveSessionRepository';
|
|
2
|
+
import {
|
|
3
|
+
OauthTokenCandidate,
|
|
4
|
+
OauthTokenSelectUseCase,
|
|
5
|
+
} from './OauthTokenSelectUseCase';
|
|
6
|
+
|
|
7
|
+
export type LiveSessionOauthTokenCandidateMetrics = {
|
|
8
|
+
name: string;
|
|
9
|
+
fiveHourFreeRatio: number;
|
|
10
|
+
sevenDayFreeRatio: number;
|
|
11
|
+
sevenDayEndEpoch: number;
|
|
12
|
+
liveSessionCount: number;
|
|
13
|
+
eligible: boolean;
|
|
14
|
+
exclusionReason: string | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type LiveSessionOauthTokenSelectResult = {
|
|
18
|
+
selected: OauthTokenCandidate | null;
|
|
19
|
+
metrics: LiveSessionOauthTokenCandidateMetrics[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export class LiveSessionOauthTokenSelectUseCase {
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly rateLimitSelectUseCase: OauthTokenSelectUseCase = new OauthTokenSelectUseCase(),
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
run = (
|
|
28
|
+
candidates: OauthTokenCandidate[],
|
|
29
|
+
liveSessions: ClaudeLiveSession[],
|
|
30
|
+
nowEpochSeconds: number,
|
|
31
|
+
): LiveSessionOauthTokenSelectResult => {
|
|
32
|
+
const rateLimitResult = this.rateLimitSelectUseCase.run(
|
|
33
|
+
candidates,
|
|
34
|
+
nowEpochSeconds,
|
|
35
|
+
);
|
|
36
|
+
const liveSessionCountByToken = this.liveSessionCountByToken(liveSessions);
|
|
37
|
+
|
|
38
|
+
const evaluated = candidates.map((candidate, index) => {
|
|
39
|
+
const rateLimitMetric = rateLimitResult.metrics[index];
|
|
40
|
+
const liveSessionCount =
|
|
41
|
+
liveSessionCountByToken.get(candidate.token) ?? 0;
|
|
42
|
+
return {
|
|
43
|
+
candidate,
|
|
44
|
+
metric: {
|
|
45
|
+
name: rateLimitMetric.name,
|
|
46
|
+
fiveHourFreeRatio: rateLimitMetric.fiveHourFreeRatio,
|
|
47
|
+
sevenDayFreeRatio: rateLimitMetric.sevenDayFreeRatio,
|
|
48
|
+
sevenDayEndEpoch: rateLimitMetric.sevenDayEndEpoch,
|
|
49
|
+
liveSessionCount,
|
|
50
|
+
eligible: rateLimitMetric.eligible,
|
|
51
|
+
exclusionReason: rateLimitMetric.exclusionReason,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const metrics = evaluated.map((entry) => entry.metric);
|
|
57
|
+
const eligible = evaluated.filter((entry) => entry.metric.eligible);
|
|
58
|
+
|
|
59
|
+
if (eligible.length === 0) {
|
|
60
|
+
return { selected: null, metrics };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const best = eligible.reduce((bestEntry, currentEntry) =>
|
|
64
|
+
this.preferred(currentEntry.metric, bestEntry.metric)
|
|
65
|
+
? currentEntry
|
|
66
|
+
: bestEntry,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return { selected: best.candidate, metrics };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
private preferred = (
|
|
73
|
+
candidateMetric: LiveSessionOauthTokenCandidateMetrics,
|
|
74
|
+
incumbentMetric: LiveSessionOauthTokenCandidateMetrics,
|
|
75
|
+
): boolean => {
|
|
76
|
+
if (candidateMetric.liveSessionCount !== incumbentMetric.liveSessionCount) {
|
|
77
|
+
return (
|
|
78
|
+
candidateMetric.liveSessionCount < incumbentMetric.liveSessionCount
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return candidateMetric.sevenDayEndEpoch < incumbentMetric.sevenDayEndEpoch;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
private liveSessionCountByToken = (
|
|
85
|
+
liveSessions: ClaudeLiveSession[],
|
|
86
|
+
): Map<string, number> => {
|
|
87
|
+
const sessionIdsByToken = new Map<string, Set<string>>();
|
|
88
|
+
for (const liveSession of liveSessions) {
|
|
89
|
+
const sessionIds =
|
|
90
|
+
sessionIdsByToken.get(liveSession.token) ?? new Set<string>();
|
|
91
|
+
sessionIds.add(liveSession.sessionId);
|
|
92
|
+
sessionIdsByToken.set(liveSession.token, sessionIds);
|
|
93
|
+
}
|
|
94
|
+
const countByToken = new Map<string, number>();
|
|
95
|
+
for (const [token, sessionIds] of sessionIdsByToken.entries()) {
|
|
96
|
+
countByToken.set(token, sessionIds.size);
|
|
97
|
+
}
|
|
98
|
+
return countByToken;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface TmuxSessionRepository {
|
|
2
|
+
listLiveSessionNames: () => Promise<string[]>;
|
|
3
|
+
listInteractiveProcessCommandLines: () => Promise<string[]>;
|
|
4
|
+
launchDetachedSession: (
|
|
5
|
+
sessionName: string,
|
|
6
|
+
launcherCommand: string,
|
|
7
|
+
issueUrl: string,
|
|
8
|
+
) => Promise<void>;
|
|
9
|
+
}
|