github-issue-tower-defence-management 1.193.0 → 1.194.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 +21 -0
- package/README.md +4 -0
- package/bin/adapter/entry-points/cli/index.js +19 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-RaFqjB7u.js → index-CYwLv9pf.js} +2 -2
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +1 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
- package/bin/adapter/repositories/NodeLocalCommandRunner.js +5 -0
- package/bin/adapter/repositories/NodeLocalCommandRunner.js.map +1 -1
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js +29 -0
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
- package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +4 -2
- package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +8 -3
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +4 -2
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/StartPreparationUseCase.js +12 -2
- package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/awaitingOwnerApprovalMessage.js +1 -1
- package/bin/domain/usecases/awaitingOwnerApprovalMessage.js.map +1 -1
- package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +1 -3
- package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +12 -4
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +102 -0
- package/src/adapter/entry-points/cli/index.ts +43 -0
- package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +45 -0
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +7 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.stories.tsx +31 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.test.tsx +96 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.tsx +20 -2
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +159 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +17 -1
- package/src/adapter/entry-points/console/ui-dist/assets/{index-RaFqjB7u.js → index-CYwLv9pf.js} +2 -2
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.test.ts +1 -0
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +1 -0
- package/src/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.test.ts +4 -1
- package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +2 -0
- package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +1 -0
- package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +1 -0
- package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.test.ts +4 -0
- package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +1 -0
- package/src/adapter/repositories/NodeLocalCommandRunner.test.ts +16 -0
- package/src/adapter/repositories/NodeLocalCommandRunner.ts +5 -1
- package/src/adapter/repositories/NodeSubAgentProcessLister.test.ts +1 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +132 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.ts +37 -0
- package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +38 -0
- package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +6 -2
- package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +2 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +78 -4
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +15 -3
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +9 -2
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +10 -2
- package/src/domain/usecases/StartPreparationUseCase.test.ts +110 -2
- package/src/domain/usecases/StartPreparationUseCase.ts +34 -17
- package/src/domain/usecases/adapter-interfaces/LocalCommandRunner.ts +1 -0
- package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +4 -0
- package/src/domain/usecases/awaitingOwnerApprovalMessage.ts +1 -1
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +5 -4
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +9 -11
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +63 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +13 -4
- package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +1 -0
- package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +1 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
- package/types/adapter/repositories/NodeLocalCommandRunner.d.ts +1 -0
- package/types/adapter/repositories/NodeLocalCommandRunner.d.ts.map +1 -1
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +1 -0
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
- package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts +1 -0
- package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +1 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
- package/types/domain/usecases/awaitingOwnerApprovalMessage.d.ts +1 -1
- package/types/domain/usecases/awaitingOwnerApprovalMessage.d.ts.map +1 -1
- package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +1 -0
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>TDPM Console</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-CYwLv9pf.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-CLHvrafO.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
@@ -34,7 +34,10 @@ describe('resetDegeneratedTmuxSessions', () => {
|
|
|
34
34
|
it('wires the real adapters and runs end to end without acting when no sessions exist', async () => {
|
|
35
35
|
const runCommand: jest.MockedFunction<LocalCommandRunner['runCommand']> =
|
|
36
36
|
jest.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
|
|
37
|
-
const commandRunner: LocalCommandRunner = {
|
|
37
|
+
const commandRunner: LocalCommandRunner = {
|
|
38
|
+
runCommand,
|
|
39
|
+
spawnInteractive: jest.fn(),
|
|
40
|
+
};
|
|
38
41
|
|
|
39
42
|
await resetDegeneratedTmuxSessions({
|
|
40
43
|
enabled: false,
|
|
@@ -413,6 +413,7 @@ describe('writeSituationFile', () => {
|
|
|
413
413
|
);
|
|
414
414
|
const mockRunner: LocalCommandRunner = {
|
|
415
415
|
runCommand: mockRunCommand,
|
|
416
|
+
spawnInteractive: jest.fn(),
|
|
416
417
|
};
|
|
417
418
|
const issues = [
|
|
418
419
|
createIssue({
|
|
@@ -444,6 +445,7 @@ describe('writeSituationFile', () => {
|
|
|
444
445
|
const mockRunCommand = jest.fn();
|
|
445
446
|
const mockRunner: LocalCommandRunner = {
|
|
446
447
|
runCommand: mockRunCommand,
|
|
448
|
+
spawnInteractive: jest.fn(),
|
|
447
449
|
};
|
|
448
450
|
|
|
449
451
|
await writeSituationFile({
|
|
@@ -84,6 +84,7 @@ describe('LocalCommandIssueAttachmentRepository', () => {
|
|
|
84
84
|
exitCode: 0,
|
|
85
85
|
};
|
|
86
86
|
},
|
|
87
|
+
spawnInteractive: jest.fn(),
|
|
87
88
|
};
|
|
88
89
|
const repository = new LocalCommandIssueAttachmentRepository(
|
|
89
90
|
runner,
|
|
@@ -117,6 +118,7 @@ describe('LocalCommandIssueAttachmentRepository', () => {
|
|
|
117
118
|
exitCode: 0,
|
|
118
119
|
};
|
|
119
120
|
},
|
|
121
|
+
spawnInteractive: jest.fn(),
|
|
120
122
|
};
|
|
121
123
|
const repository = new LocalCommandIssueAttachmentRepository(
|
|
122
124
|
runner,
|
|
@@ -148,6 +150,7 @@ describe('LocalCommandIssueAttachmentRepository', () => {
|
|
|
148
150
|
stderr: 'Error: No GitHub web session is available\n',
|
|
149
151
|
exitCode: 1,
|
|
150
152
|
}),
|
|
153
|
+
spawnInteractive: jest.fn(),
|
|
151
154
|
};
|
|
152
155
|
const repository = new LocalCommandIssueAttachmentRepository(
|
|
153
156
|
runner,
|
|
@@ -171,6 +174,7 @@ describe('LocalCommandIssueAttachmentRepository', () => {
|
|
|
171
174
|
const root = await mkdtemp(join(tmpdir(), 'attachment-test-root-'));
|
|
172
175
|
const runner: LocalCommandRunner = {
|
|
173
176
|
runCommand: async () => ({ stdout: '\n', stderr: '', exitCode: 0 }),
|
|
177
|
+
spawnInteractive: jest.fn(),
|
|
174
178
|
};
|
|
175
179
|
const repository = new LocalCommandIssueAttachmentRepository(
|
|
176
180
|
runner,
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
const mockExecFileAsync = jest.fn();
|
|
2
|
+
const mockSpawnSync = jest.fn();
|
|
2
3
|
|
|
3
4
|
jest.mock('child_process', () => ({
|
|
4
5
|
execFile: jest.fn(),
|
|
6
|
+
spawnSync: jest.fn((...args: unknown[]) => {
|
|
7
|
+
mockSpawnSync(...args);
|
|
8
|
+
}),
|
|
5
9
|
}));
|
|
6
10
|
|
|
7
11
|
jest.mock('util', () => ({
|
|
@@ -99,4 +103,16 @@ describe('NodeLocalCommandRunner', () => {
|
|
|
99
103
|
);
|
|
100
104
|
});
|
|
101
105
|
});
|
|
106
|
+
|
|
107
|
+
describe('spawnInteractive', () => {
|
|
108
|
+
it('calls spawnSync with stdio inherit', () => {
|
|
109
|
+
runner.spawnInteractive('tmux', ['attach-session', '-t', '=wq7x3mk']);
|
|
110
|
+
|
|
111
|
+
expect(mockSpawnSync).toHaveBeenCalledWith(
|
|
112
|
+
'tmux',
|
|
113
|
+
['attach-session', '-t', '=wq7x3mk'],
|
|
114
|
+
{ stdio: 'inherit' },
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
102
118
|
});
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
LocalCommandRunner,
|
|
3
3
|
LocalCommandRunnerOptions,
|
|
4
4
|
} from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
5
|
-
import { execFile } from 'child_process';
|
|
5
|
+
import { execFile, spawnSync } from 'child_process';
|
|
6
6
|
import { promisify } from 'util';
|
|
7
7
|
|
|
8
8
|
const execFileAsync = promisify(execFile);
|
|
@@ -59,4 +59,8 @@ export class NodeLocalCommandRunner implements LocalCommandRunner {
|
|
|
59
59
|
throw error;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
spawnInteractive = (program: string, args: string[]): void => {
|
|
64
|
+
spawnSync(program, args, { stdio: 'inherit' });
|
|
65
|
+
};
|
|
62
66
|
}
|
|
@@ -8,6 +8,7 @@ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
|
|
|
8
8
|
|
|
9
9
|
const createMockRunner = (): Mocked<LocalCommandRunner> => ({
|
|
10
10
|
runCommand: jest.fn(),
|
|
11
|
+
spawnInteractive: jest.fn(),
|
|
11
12
|
});
|
|
12
13
|
|
|
13
14
|
describe('NodeTmuxSessionRepository', () => {
|
|
@@ -411,4 +412,135 @@ describe('NodeTmuxSessionRepository', () => {
|
|
|
411
412
|
).rejects.toThrow('Failed to relaunch bare-name leader session "app"');
|
|
412
413
|
});
|
|
413
414
|
});
|
|
415
|
+
|
|
416
|
+
describe('attachOrCreateInteractiveSession', () => {
|
|
417
|
+
const issueUrl = 'https://github.com/owner/repo/issues/9';
|
|
418
|
+
|
|
419
|
+
it('attaches to the registered session when the registry returns a live session name', async () => {
|
|
420
|
+
const runner = createMockRunner();
|
|
421
|
+
runner.runCommand
|
|
422
|
+
.mockResolvedValueOnce({ stdout: 'wq7x3mk\n', stderr: '', exitCode: 0 })
|
|
423
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
424
|
+
const repository = new NodeTmuxSessionRepository(runner);
|
|
425
|
+
|
|
426
|
+
await repository.attachOrCreateInteractiveSession(
|
|
427
|
+
issueUrl,
|
|
428
|
+
'/path/to/cl-scope-lib.sh',
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
expect(runner.runCommand.mock.calls[0]).toEqual([
|
|
432
|
+
'bash',
|
|
433
|
+
['/path/to/cl-scope-lib.sh', 'registry-get-session', issueUrl],
|
|
434
|
+
]);
|
|
435
|
+
expect(runner.runCommand.mock.calls[1]).toEqual([
|
|
436
|
+
'tmux',
|
|
437
|
+
['has-session', '-t', '=wq7x3mk'],
|
|
438
|
+
]);
|
|
439
|
+
expect(runner.spawnInteractive.mock.calls).toEqual([
|
|
440
|
+
['tmux', ['attach-session', '-t', '=wq7x3mk']],
|
|
441
|
+
]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('creates a new session when no session is registered', async () => {
|
|
445
|
+
const runner = createMockRunner();
|
|
446
|
+
runner.runCommand.mockResolvedValueOnce({
|
|
447
|
+
stdout: '',
|
|
448
|
+
stderr: '',
|
|
449
|
+
exitCode: 0,
|
|
450
|
+
});
|
|
451
|
+
const repository = new NodeTmuxSessionRepository(runner);
|
|
452
|
+
|
|
453
|
+
await repository.attachOrCreateInteractiveSession(
|
|
454
|
+
issueUrl,
|
|
455
|
+
'/path/to/cl-scope-lib.sh',
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
expect(runner.runCommand.mock.calls).toHaveLength(1);
|
|
459
|
+
expect(runner.spawnInteractive.mock.calls).toEqual([
|
|
460
|
+
['tmux', ['new-session', '-A', '-s', issueUrl, 'cl', issueUrl]],
|
|
461
|
+
]);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('creates a new session when the registered session is no longer alive', async () => {
|
|
465
|
+
const runner = createMockRunner();
|
|
466
|
+
runner.runCommand
|
|
467
|
+
.mockResolvedValueOnce({ stdout: 'wq7x3mk\n', stderr: '', exitCode: 0 })
|
|
468
|
+
.mockResolvedValueOnce({
|
|
469
|
+
stdout: '',
|
|
470
|
+
stderr: "can't find session",
|
|
471
|
+
exitCode: 1,
|
|
472
|
+
});
|
|
473
|
+
const repository = new NodeTmuxSessionRepository(runner);
|
|
474
|
+
|
|
475
|
+
await repository.attachOrCreateInteractiveSession(
|
|
476
|
+
issueUrl,
|
|
477
|
+
'/path/to/cl-scope-lib.sh',
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
expect(runner.runCommand.mock.calls[1]).toEqual([
|
|
481
|
+
'tmux',
|
|
482
|
+
['has-session', '-t', '=wq7x3mk'],
|
|
483
|
+
]);
|
|
484
|
+
expect(runner.spawnInteractive.mock.calls).toEqual([
|
|
485
|
+
['tmux', ['new-session', '-A', '-s', issueUrl, 'cl', issueUrl]],
|
|
486
|
+
]);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('creates a new session when the registry lookup fails', async () => {
|
|
490
|
+
const runner = createMockRunner();
|
|
491
|
+
runner.runCommand.mockResolvedValueOnce({
|
|
492
|
+
stdout: '',
|
|
493
|
+
stderr: 'registry error',
|
|
494
|
+
exitCode: 1,
|
|
495
|
+
});
|
|
496
|
+
const repository = new NodeTmuxSessionRepository(runner);
|
|
497
|
+
|
|
498
|
+
await repository.attachOrCreateInteractiveSession(
|
|
499
|
+
issueUrl,
|
|
500
|
+
'/path/to/cl-scope-lib.sh',
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
expect(runner.runCommand.mock.calls).toHaveLength(1);
|
|
504
|
+
expect(runner.spawnInteractive.mock.calls).toEqual([
|
|
505
|
+
['tmux', ['new-session', '-A', '-s', issueUrl, 'cl', issueUrl]],
|
|
506
|
+
]);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('creates a new session without calling the registry when no scope lib path is provided', async () => {
|
|
510
|
+
const runner = createMockRunner();
|
|
511
|
+
const repository = new NodeTmuxSessionRepository(runner);
|
|
512
|
+
|
|
513
|
+
await repository.attachOrCreateInteractiveSession(issueUrl, null);
|
|
514
|
+
|
|
515
|
+
expect(runner.runCommand.mock.calls).toHaveLength(0);
|
|
516
|
+
expect(runner.spawnInteractive.mock.calls).toEqual([
|
|
517
|
+
['tmux', ['new-session', '-A', '-s', issueUrl, 'cl', issueUrl]],
|
|
518
|
+
]);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('trims whitespace from the registered session name before checking tmux', async () => {
|
|
522
|
+
const runner = createMockRunner();
|
|
523
|
+
runner.runCommand
|
|
524
|
+
.mockResolvedValueOnce({
|
|
525
|
+
stdout: ' abc123 \n',
|
|
526
|
+
stderr: '',
|
|
527
|
+
exitCode: 0,
|
|
528
|
+
})
|
|
529
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
530
|
+
const repository = new NodeTmuxSessionRepository(runner);
|
|
531
|
+
|
|
532
|
+
await repository.attachOrCreateInteractiveSession(
|
|
533
|
+
issueUrl,
|
|
534
|
+
'/path/to/cl-scope-lib.sh',
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
expect(runner.runCommand.mock.calls[1]).toEqual([
|
|
538
|
+
'tmux',
|
|
539
|
+
['has-session', '-t', '=abc123'],
|
|
540
|
+
]);
|
|
541
|
+
expect(runner.spawnInteractive.mock.calls).toEqual([
|
|
542
|
+
['tmux', ['attach-session', '-t', '=abc123']],
|
|
543
|
+
]);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
414
546
|
});
|
|
@@ -165,6 +165,43 @@ export class NodeTmuxSessionRepository implements TmuxSessionRepository {
|
|
|
165
165
|
await this.sendEnter(sessionName);
|
|
166
166
|
};
|
|
167
167
|
|
|
168
|
+
attachOrCreateInteractiveSession = async (
|
|
169
|
+
issueUrl: string,
|
|
170
|
+
scopeLibPath: string | null,
|
|
171
|
+
): Promise<void> => {
|
|
172
|
+
if (scopeLibPath !== null) {
|
|
173
|
+
const { stdout, exitCode } = await this.localCommandRunner.runCommand(
|
|
174
|
+
'bash',
|
|
175
|
+
[scopeLibPath, 'registry-get-session', issueUrl],
|
|
176
|
+
);
|
|
177
|
+
if (exitCode === 0 && stdout.trim().length > 0) {
|
|
178
|
+
const registeredSessionName = stdout.trim();
|
|
179
|
+
const { exitCode: hasSessionExitCode } =
|
|
180
|
+
await this.localCommandRunner.runCommand('tmux', [
|
|
181
|
+
'has-session',
|
|
182
|
+
'-t',
|
|
183
|
+
`=${registeredSessionName}`,
|
|
184
|
+
]);
|
|
185
|
+
if (hasSessionExitCode === 0) {
|
|
186
|
+
this.localCommandRunner.spawnInteractive('tmux', [
|
|
187
|
+
'attach-session',
|
|
188
|
+
'-t',
|
|
189
|
+
`=${registeredSessionName}`,
|
|
190
|
+
]);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
this.localCommandRunner.spawnInteractive('tmux', [
|
|
196
|
+
'new-session',
|
|
197
|
+
'-A',
|
|
198
|
+
'-s',
|
|
199
|
+
issueUrl,
|
|
200
|
+
'cl',
|
|
201
|
+
issueUrl,
|
|
202
|
+
]);
|
|
203
|
+
};
|
|
204
|
+
|
|
168
205
|
launchBareNameLeaderSession = async (name: string): Promise<void> => {
|
|
169
206
|
const sessionName = name.replace(/[.:]/g, '_');
|
|
170
207
|
const leaderCommand = `cl ${shellSingleQuote(name)}; exec /bin/bash`;
|
|
@@ -74,6 +74,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
74
74
|
|
|
75
75
|
const futureReset = Math.floor(Date.now() / 1000) + 3600;
|
|
76
76
|
const pastReset = Math.floor(Date.now() / 1000) - 3600;
|
|
77
|
+
const observedAfterReset = Math.floor(Date.now() / 1000) - 60;
|
|
77
78
|
|
|
78
79
|
it('should map each token to its cached utilization and include name', async () => {
|
|
79
80
|
mockLoadTokenEntries.mockReturnValue([
|
|
@@ -210,6 +211,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
210
211
|
mockReadRateLimit.mockReturnValue({
|
|
211
212
|
fiveHourUtilization: 100,
|
|
212
213
|
fiveHourReset: pastReset,
|
|
214
|
+
lastUpdatedEpoch: observedAfterReset,
|
|
213
215
|
sevenDayUtilization: 30,
|
|
214
216
|
sevenDayReset: futureReset,
|
|
215
217
|
blocked: false,
|
|
@@ -284,6 +286,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
284
286
|
mockReadRateLimit.mockReturnValue({
|
|
285
287
|
fiveHourUtilization: 100,
|
|
286
288
|
fiveHourReset: pastReset,
|
|
289
|
+
lastUpdatedEpoch: observedAfterReset,
|
|
287
290
|
sevenDayUtilization: 0,
|
|
288
291
|
sevenDayReset: futureReset,
|
|
289
292
|
blocked: false,
|
|
@@ -323,6 +326,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
323
326
|
fiveHourReset: futureReset,
|
|
324
327
|
sevenDayUtilization: 100,
|
|
325
328
|
sevenDayReset: pastReset,
|
|
329
|
+
lastUpdatedEpoch: observedAfterReset,
|
|
326
330
|
blocked: false,
|
|
327
331
|
rejected: true,
|
|
328
332
|
unifiedRejected: false,
|
|
@@ -393,6 +397,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
393
397
|
mockReadRateLimit.mockReturnValue({
|
|
394
398
|
fiveHourUtilization: 100,
|
|
395
399
|
fiveHourReset: pastReset,
|
|
400
|
+
lastUpdatedEpoch: observedAfterReset,
|
|
396
401
|
sevenDayUtilization: 100,
|
|
397
402
|
sevenDayReset: futureReset,
|
|
398
403
|
blocked: false,
|
|
@@ -430,6 +435,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
430
435
|
mockReadRateLimit.mockReturnValue({
|
|
431
436
|
fiveHourUtilization: 100,
|
|
432
437
|
fiveHourReset: pastReset,
|
|
438
|
+
lastUpdatedEpoch: observedAfterReset,
|
|
433
439
|
sevenDayUtilization: 0,
|
|
434
440
|
sevenDayReset: futureReset,
|
|
435
441
|
blocked: false,
|
|
@@ -734,6 +740,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
734
740
|
fiveHourReset: futureReset,
|
|
735
741
|
sevenDayUtilization: 0,
|
|
736
742
|
sevenDayReset: pastReset,
|
|
743
|
+
lastUpdatedEpoch: observedAfterReset,
|
|
737
744
|
blocked: false,
|
|
738
745
|
rejected: false,
|
|
739
746
|
unifiedRejected: false,
|
|
@@ -1130,3 +1137,34 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
1130
1137
|
});
|
|
1131
1138
|
});
|
|
1132
1139
|
});
|
|
1140
|
+
|
|
1141
|
+
describe('ProxyClaudeTokenUsageRepository window rollover inference', () => {
|
|
1142
|
+
beforeEach(() => {
|
|
1143
|
+
jest.clearAllMocks();
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
it('should keep the last observed sevenDayUtilization when the snapshot predates the 7d reset it reports', async () => {
|
|
1147
|
+
const pastReset = Math.floor(Date.now() / 1000) - 3600;
|
|
1148
|
+
const observedBeforeReset = pastReset - 72000;
|
|
1149
|
+
mockLoadTokenEntries.mockReturnValue([{ name: 'alice', token: 'token-a' }]);
|
|
1150
|
+
mockReadRateLimit.mockReturnValue({
|
|
1151
|
+
fiveHourUtilization: 0,
|
|
1152
|
+
fiveHourReset: Math.floor(Date.now() / 1000) + 3600,
|
|
1153
|
+
sevenDayUtilization: 98,
|
|
1154
|
+
sevenDayReset: pastReset,
|
|
1155
|
+
blocked: false,
|
|
1156
|
+
rejected: true,
|
|
1157
|
+
unifiedRejected: false,
|
|
1158
|
+
fiveHourRejected: false,
|
|
1159
|
+
sevenDayRejected: true,
|
|
1160
|
+
lastUpdatedEpoch: observedBeforeReset,
|
|
1161
|
+
modelWeeklyLimits: {},
|
|
1162
|
+
});
|
|
1163
|
+
const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
|
|
1164
|
+
|
|
1165
|
+
const result = await repository.getAvailableTokenUsages();
|
|
1166
|
+
|
|
1167
|
+
expect(result[0].sevenDayUtilization).toBe(98);
|
|
1168
|
+
expect(result[0].rejected).toBe(true);
|
|
1169
|
+
});
|
|
1170
|
+
});
|
|
@@ -43,8 +43,12 @@ export class ProxyClaudeTokenUsageRepository implements ClaudeTokenUsageReposito
|
|
|
43
43
|
selectionWeight,
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
|
-
const fiveHourExpired =
|
|
47
|
-
|
|
46
|
+
const fiveHourExpired =
|
|
47
|
+
nowEpochSeconds > snapshot.fiveHourReset &&
|
|
48
|
+
snapshot.lastUpdatedEpoch >= snapshot.fiveHourReset;
|
|
49
|
+
const sevenDayExpired =
|
|
50
|
+
nowEpochSeconds > snapshot.sevenDayReset &&
|
|
51
|
+
snapshot.lastUpdatedEpoch >= snapshot.sevenDayReset;
|
|
48
52
|
const fiveHourUtilization = fiveHourExpired
|
|
49
53
|
? 0
|
|
50
54
|
: snapshot.fiveHourUtilization;
|
|
@@ -10,6 +10,7 @@ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
|
|
|
10
10
|
stderr: '',
|
|
11
11
|
exitCode: 0,
|
|
12
12
|
}),
|
|
13
|
+
spawnInteractive: jest.fn(),
|
|
13
14
|
});
|
|
14
15
|
|
|
15
16
|
const createMockSleeper = (): Mocked<Sleeper> => ({
|
|
@@ -42,6 +43,7 @@ const createPaneAwareRunner = (
|
|
|
42
43
|
capturedPaneIndex += 1;
|
|
43
44
|
return Promise.resolve({ stdout: paneText, stderr: '', exitCode: 0 });
|
|
44
45
|
}),
|
|
46
|
+
spawnInteractive: jest.fn(),
|
|
45
47
|
};
|
|
46
48
|
};
|
|
47
49
|
|
|
@@ -38,6 +38,12 @@ const createMockProject = (overrides: Partial<Project> = {}): Project => ({
|
|
|
38
38
|
color: 'BLUE',
|
|
39
39
|
description: '',
|
|
40
40
|
},
|
|
41
|
+
{
|
|
42
|
+
id: 'todo-by-human-id',
|
|
43
|
+
name: 'Todo by human',
|
|
44
|
+
color: 'GREEN',
|
|
45
|
+
description: '',
|
|
46
|
+
},
|
|
41
47
|
],
|
|
42
48
|
},
|
|
43
49
|
nextActionDate: null,
|
|
@@ -4669,7 +4675,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
4669
4675
|
describe('waitingForOwnerApproval', () => {
|
|
4670
4676
|
const issueUrl = 'https://github.com/user/repo/issues/1';
|
|
4671
4677
|
|
|
4672
|
-
it('
|
|
4678
|
+
it('moves to Todo by human and posts AWAITING_OWNER_APPROVAL message when last report declares waitingForOwnerApproval', async () => {
|
|
4673
4679
|
const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
|
|
4674
4680
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
4675
4681
|
mockIssueRepository.get.mockResolvedValue(issue);
|
|
@@ -4690,13 +4696,13 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
4690
4696
|
});
|
|
4691
4697
|
|
|
4692
4698
|
expect(mockIssueRepository.update).toHaveBeenCalledWith(
|
|
4693
|
-
expect.objectContaining({ status: '
|
|
4699
|
+
expect.objectContaining({ status: 'Todo by human' }),
|
|
4694
4700
|
mockProject,
|
|
4695
4701
|
);
|
|
4696
4702
|
expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
|
|
4697
4703
|
mockProject,
|
|
4698
|
-
expect.objectContaining({ status: '
|
|
4699
|
-
'
|
|
4704
|
+
expect.objectContaining({ status: 'Todo by human' }),
|
|
4705
|
+
'todo-by-human-id',
|
|
4700
4706
|
);
|
|
4701
4707
|
expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
|
|
4702
4708
|
expect.objectContaining({ url: issueUrl }),
|
|
@@ -4709,6 +4715,74 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
4709
4715
|
);
|
|
4710
4716
|
});
|
|
4711
4717
|
|
|
4718
|
+
it('does not return the issue to the dispatch pool when it is waiting for owner approval', async () => {
|
|
4719
|
+
const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
|
|
4720
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
4721
|
+
mockIssueRepository.get.mockResolvedValue(issue);
|
|
4722
|
+
mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
|
|
4723
|
+
createMockComment({
|
|
4724
|
+
content:
|
|
4725
|
+
'From: :robot: systems-analyst (model)\n```json\n{"pullRequestRequired": false, "waitingForOwnerApproval": true}\n```',
|
|
4726
|
+
}),
|
|
4727
|
+
]);
|
|
4728
|
+
mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
|
|
4729
|
+
|
|
4730
|
+
await useCase.run({
|
|
4731
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
4732
|
+
issueUrl,
|
|
4733
|
+
thresholdForAutoReject: 3,
|
|
4734
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
4735
|
+
allowedIssueAuthors: null,
|
|
4736
|
+
});
|
|
4737
|
+
|
|
4738
|
+
expect(mockIssueRepository.updateStatus).not.toHaveBeenCalledWith(
|
|
4739
|
+
mockProject,
|
|
4740
|
+
expect.anything(),
|
|
4741
|
+
'awaiting-workspace-id',
|
|
4742
|
+
);
|
|
4743
|
+
});
|
|
4744
|
+
|
|
4745
|
+
it('escalates to Failed Preparation instead of the dispatch pool when the project has no Todo by human status', async () => {
|
|
4746
|
+
const projectWithoutTodo = {
|
|
4747
|
+
...mockProject,
|
|
4748
|
+
status: {
|
|
4749
|
+
...mockProject.status,
|
|
4750
|
+
statuses: mockProject.status.statuses.filter(
|
|
4751
|
+
(status) => status.name !== 'Todo by human',
|
|
4752
|
+
),
|
|
4753
|
+
},
|
|
4754
|
+
};
|
|
4755
|
+
const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
|
|
4756
|
+
mockProjectRepository.getByUrl.mockResolvedValue(projectWithoutTodo);
|
|
4757
|
+
mockIssueRepository.get.mockResolvedValue(issue);
|
|
4758
|
+
mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
|
|
4759
|
+
createMockComment({
|
|
4760
|
+
content:
|
|
4761
|
+
'From: :robot: systems-analyst (model)\n```json\n{"pullRequestRequired": false, "waitingForOwnerApproval": true}\n```',
|
|
4762
|
+
}),
|
|
4763
|
+
]);
|
|
4764
|
+
mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
|
|
4765
|
+
|
|
4766
|
+
await useCase.run({
|
|
4767
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
4768
|
+
issueUrl,
|
|
4769
|
+
thresholdForAutoReject: 3,
|
|
4770
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
4771
|
+
allowedIssueAuthors: null,
|
|
4772
|
+
});
|
|
4773
|
+
|
|
4774
|
+
expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
|
|
4775
|
+
projectWithoutTodo,
|
|
4776
|
+
expect.objectContaining({ status: 'Failed Preparation' }),
|
|
4777
|
+
'failed-preparation-id',
|
|
4778
|
+
);
|
|
4779
|
+
expect(mockIssueRepository.updateStatus).not.toHaveBeenCalledWith(
|
|
4780
|
+
projectWithoutTodo,
|
|
4781
|
+
expect.anything(),
|
|
4782
|
+
'awaiting-workspace-id',
|
|
4783
|
+
);
|
|
4784
|
+
});
|
|
4785
|
+
|
|
4712
4786
|
it('escalates to Failed Preparation after ownerApprovalTimeoutCycles AWAITING_OWNER_APPROVAL messages', async () => {
|
|
4713
4787
|
const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
|
|
4714
4788
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
AWAITING_WORKSPACE_STATUS_NAME,
|
|
9
9
|
FAILED_PREPARATION_STATUS_NAME,
|
|
10
10
|
PREPARATION_STATUS_NAME,
|
|
11
|
+
TODO_STATUS_NAME,
|
|
11
12
|
} from '../entities/WorkflowStatus';
|
|
12
13
|
import {
|
|
13
14
|
IssueRejectionEvaluator,
|
|
@@ -157,6 +158,9 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
157
158
|
);
|
|
158
159
|
return;
|
|
159
160
|
}
|
|
161
|
+
const todoStatusOption = project.status.statuses.find(
|
|
162
|
+
(s) => s.name === TODO_STATUS_NAME,
|
|
163
|
+
);
|
|
160
164
|
|
|
161
165
|
const issue = await this.issueRepository.get(params.issueUrl, project);
|
|
162
166
|
|
|
@@ -326,13 +330,21 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
326
330
|
isTrustedAuthor(comment.author) &&
|
|
327
331
|
comment.content.startsWith(AWAITING_OWNER_APPROVAL_MESSAGE_HEAD),
|
|
328
332
|
).length;
|
|
329
|
-
if (
|
|
330
|
-
|
|
333
|
+
if (!todoStatusOption) {
|
|
334
|
+
console.error(
|
|
335
|
+
`Todo status option '${TODO_STATUS_NAME}' not found in project; escalating to '${FAILED_PREPARATION_STATUS_NAME}' instead of returning the task to the dispatch pool.`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (
|
|
339
|
+
awaitingOwnerApprovalCount < ownerApprovalTimeoutCycles &&
|
|
340
|
+
todoStatusOption
|
|
341
|
+
) {
|
|
342
|
+
issue.status = TODO_STATUS_NAME;
|
|
331
343
|
await this.issueRepository.update(issue, project);
|
|
332
344
|
await this.issueRepository.updateStatus(
|
|
333
345
|
project,
|
|
334
346
|
issue,
|
|
335
|
-
|
|
347
|
+
todoStatusOption.id,
|
|
336
348
|
);
|
|
337
349
|
await this.patchConsoleTab(issue);
|
|
338
350
|
await this.issueCommentRepository.createComment(
|