github-issue-tower-defence-management 1.104.3 → 1.105.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 +20 -0
- package/README.md +8 -7
- package/bin/adapter/entry-points/cli/index.js +21 -13
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/{consoleServer.js → webServer.js} +11 -11
- package/bin/adapter/entry-points/console/webServer.js.map +1 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +41 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/dashboardRowWriter.js +34 -0
- package/bin/adapter/entry-points/handlers/dashboardRowWriter.js.map +1 -0
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js +60 -0
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +98 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -0
- package/bin/adapter/proxy/RateLimitCache.js +3 -0
- package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
- package/bin/adapter/repositories/ProcHostMetricsRepository.js +136 -0
- package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -0
- package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js +131 -0
- package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js.map +1 -0
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +93 -8
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
- package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js.map +1 -0
- package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js +38 -0
- package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js.map +1 -0
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +115 -0
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +74 -21
- package/src/adapter/entry-points/cli/index.ts +146 -136
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -2
- package/src/adapter/entry-points/console/{consoleServer.test.ts → webServer.test.ts} +32 -32
- package/src/adapter/entry-points/console/{consoleServer.ts → webServer.ts} +15 -17
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +52 -0
- package/src/adapter/entry-points/handlers/dashboardRowWriter.test.ts +111 -0
- package/src/adapter/entry-points/handlers/dashboardRowWriter.ts +51 -0
- package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +100 -0
- package/src/adapter/entry-points/handlers/machineStatusWriter.ts +79 -0
- package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +176 -0
- package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +139 -0
- package/src/adapter/proxy/RateLimitCache.test.ts +32 -0
- package/src/adapter/proxy/RateLimitCache.ts +6 -0
- package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +135 -0
- package/src/adapter/repositories/ProcHostMetricsRepository.ts +136 -0
- package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.test.ts +130 -0
- package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.ts +118 -0
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +267 -13
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +140 -47
- package/src/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.ts +8 -0
- package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.test.ts +159 -0
- package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.ts +72 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +209 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +195 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/{consoleServer.d.ts → webServer.d.ts} +7 -7
- package/types/adapter/entry-points/console/webServer.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts +15 -0
- package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +16 -0
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts +21 -0
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -0
- package/types/adapter/proxy/RateLimitCache.d.ts +2 -0
- package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +23 -0
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -0
- package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts +11 -0
- package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts.map +1 -0
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +6 -1
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts +8 -0
- package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts.map +1 -0
- package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts +19 -0
- package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts.map +1 -0
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +53 -0
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -0
- package/bin/adapter/entry-points/console/consoleServer.js.map +0 -1
- package/types/adapter/entry-points/console/consoleServer.d.ts.map +0 -1
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
} from './consoleOperationApi';
|
|
28
28
|
import { ImageFetcher, fetchProxiedImage } from './consoleImageProxy';
|
|
29
29
|
|
|
30
|
-
export const
|
|
30
|
+
export const DEFAULT_WEB_PORT = 9981;
|
|
31
31
|
|
|
32
32
|
export const CONSOLE_TOKEN_HEADER = 'x-pv-token';
|
|
33
33
|
|
|
@@ -152,7 +152,7 @@ const readStaticFile = (filePath: string): Buffer | null => {
|
|
|
152
152
|
}
|
|
153
153
|
};
|
|
154
154
|
|
|
155
|
-
export type
|
|
155
|
+
export type WebServerOptions = {
|
|
156
156
|
accessToken: string;
|
|
157
157
|
uiDistDir: string;
|
|
158
158
|
consoleDataOutputDir: string | null;
|
|
@@ -236,7 +236,7 @@ const serveBootstrapIndex = (response: http.ServerResponse): void => {
|
|
|
236
236
|
};
|
|
237
237
|
|
|
238
238
|
const serveIndexHtml = (
|
|
239
|
-
options:
|
|
239
|
+
options: WebServerOptions,
|
|
240
240
|
response: http.ServerResponse,
|
|
241
241
|
): void => {
|
|
242
242
|
const indexFilePath = resolveStaticFilePath(options.uiDistDir, '/index.html');
|
|
@@ -279,7 +279,7 @@ const sendImage = (
|
|
|
279
279
|
};
|
|
280
280
|
|
|
281
281
|
const handleImageProxy = async (
|
|
282
|
-
options:
|
|
282
|
+
options: WebServerOptions,
|
|
283
283
|
response: http.ServerResponse,
|
|
284
284
|
searchParams: URLSearchParams,
|
|
285
285
|
): Promise<void> => {
|
|
@@ -342,7 +342,7 @@ const parseRequestBody = (raw: string): Record<string, unknown> | null => {
|
|
|
342
342
|
};
|
|
343
343
|
|
|
344
344
|
const handleReadApi = async (
|
|
345
|
-
options:
|
|
345
|
+
options: WebServerOptions,
|
|
346
346
|
requestPath: string,
|
|
347
347
|
searchParams: URLSearchParams,
|
|
348
348
|
): Promise<{ statusCode: number; body: unknown } | null> => {
|
|
@@ -402,7 +402,7 @@ const dispatchOperation = (
|
|
|
402
402
|
};
|
|
403
403
|
|
|
404
404
|
const handleOperationApi = async (
|
|
405
|
-
options:
|
|
405
|
+
options: WebServerOptions,
|
|
406
406
|
requestPath: string,
|
|
407
407
|
body: Record<string, unknown>,
|
|
408
408
|
): Promise<{ statusCode: number; body: unknown } | null> => {
|
|
@@ -432,7 +432,7 @@ const handleOperationApi = async (
|
|
|
432
432
|
};
|
|
433
433
|
|
|
434
434
|
const handleTokenedRequest = async (
|
|
435
|
-
options:
|
|
435
|
+
options: WebServerOptions,
|
|
436
436
|
request: http.IncomingMessage,
|
|
437
437
|
response: http.ServerResponse,
|
|
438
438
|
requestPath: string,
|
|
@@ -525,8 +525,8 @@ const handleTokenedRequest = async (
|
|
|
525
525
|
sendNotFound(response);
|
|
526
526
|
};
|
|
527
527
|
|
|
528
|
-
export const
|
|
529
|
-
options:
|
|
528
|
+
export const handleWebRequest = async (
|
|
529
|
+
options: WebServerOptions,
|
|
530
530
|
request: http.IncomingMessage,
|
|
531
531
|
response: http.ServerResponse,
|
|
532
532
|
): Promise<void> => {
|
|
@@ -624,25 +624,23 @@ const sendInternalServerError = (response: http.ServerResponse): void => {
|
|
|
624
624
|
response.end('Internal Server Error');
|
|
625
625
|
};
|
|
626
626
|
|
|
627
|
-
export const
|
|
628
|
-
options: ConsoleServerOptions,
|
|
629
|
-
): http.Server =>
|
|
627
|
+
export const createWebServer = (options: WebServerOptions): http.Server =>
|
|
630
628
|
http.createServer((request, response) => {
|
|
631
|
-
|
|
629
|
+
handleWebRequest(options, request, response).catch((error) => {
|
|
632
630
|
console.error('console request failed', error);
|
|
633
631
|
sendInternalServerError(response);
|
|
634
632
|
});
|
|
635
633
|
});
|
|
636
634
|
|
|
637
|
-
export type
|
|
635
|
+
export type StartWebServerOptions = WebServerOptions & {
|
|
638
636
|
port: number;
|
|
639
637
|
};
|
|
640
638
|
|
|
641
|
-
export const
|
|
642
|
-
options:
|
|
639
|
+
export const startWebServer = (
|
|
640
|
+
options: StartWebServerOptions,
|
|
643
641
|
): Promise<http.Server> =>
|
|
644
642
|
new Promise((resolve, reject) => {
|
|
645
|
-
const server =
|
|
643
|
+
const server = createWebServer(options);
|
|
646
644
|
server.once('error', reject);
|
|
647
645
|
server.listen(options.port, () => {
|
|
648
646
|
server.removeListener('error', reject);
|
|
@@ -3,6 +3,9 @@ import TYPIA from 'typia';
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import { writeSituationFile } from './situationFileWriter';
|
|
5
5
|
import { writeConsoleLists } from './consoleListsWriter';
|
|
6
|
+
import { writeDashboardRow } from './dashboardRowWriter';
|
|
7
|
+
import { writeMachineStatus } from './machineStatusWriter';
|
|
8
|
+
import { writeTokenStatus } from './tokenStatusWriter';
|
|
6
9
|
import { writeInTmuxByHumanData } from './inTmuxByHumanDataWriter';
|
|
7
10
|
import { reconcileInTmuxByHumanSessions } from './inTmuxByHumanSessionReconciler';
|
|
8
11
|
import { writeRotationOrderFile } from './rotationOrderFileWriter';
|
|
@@ -55,6 +58,8 @@ import {
|
|
|
55
58
|
PREPARATION_STATUS_NAME,
|
|
56
59
|
} from '../../../domain/entities/WorkflowStatus';
|
|
57
60
|
|
|
61
|
+
const DEFAULT_DASHBOARD_DATA_DIR: string | null = null;
|
|
62
|
+
|
|
58
63
|
export class HandleScheduledEventUseCaseHandler {
|
|
59
64
|
handle = async (
|
|
60
65
|
configFilePath: string,
|
|
@@ -74,6 +79,7 @@ export class HandleScheduledEventUseCaseHandler {
|
|
|
74
79
|
allowedIssueAuthors?: string | string[] | null;
|
|
75
80
|
claudeCodeOauthTokenListJsonPath?: string;
|
|
76
81
|
consoleDataOutputDir?: string;
|
|
82
|
+
dashboardDataDir?: string;
|
|
77
83
|
workflowBlockerStoryName?: string;
|
|
78
84
|
inTmuxDataOutputDir?: string;
|
|
79
85
|
inTmuxConsoleBaseUrl?: string;
|
|
@@ -416,6 +422,52 @@ export class HandleScheduledEventUseCaseHandler {
|
|
|
416
422
|
);
|
|
417
423
|
}
|
|
418
424
|
|
|
425
|
+
const dashboardDataDir =
|
|
426
|
+
mergedInput.dashboardDataDir ?? DEFAULT_DASHBOARD_DATA_DIR;
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
writeDashboardRow({
|
|
430
|
+
dashboardDataDir,
|
|
431
|
+
pjcode: input.projectName,
|
|
432
|
+
assigneeLogin: input.manager,
|
|
433
|
+
issues: result.issues,
|
|
434
|
+
});
|
|
435
|
+
} catch (error) {
|
|
436
|
+
console.error(
|
|
437
|
+
`Failed to write dashboard row: ${
|
|
438
|
+
error instanceof Error ? error.message : String(error)
|
|
439
|
+
}`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
await writeMachineStatus({
|
|
445
|
+
dashboardDataDir,
|
|
446
|
+
allIssuesCacheDir: `${cachePath}/allIssues-${result.project.id}`,
|
|
447
|
+
});
|
|
448
|
+
} catch (error) {
|
|
449
|
+
console.error(
|
|
450
|
+
`Failed to write machine status: ${
|
|
451
|
+
error instanceof Error ? error.message : String(error)
|
|
452
|
+
}`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
writeTokenStatus({
|
|
458
|
+
dashboardDataDir,
|
|
459
|
+
tokenListJsonPath:
|
|
460
|
+
mergedInput.claudeCodeOauthTokenListJsonPath ?? null,
|
|
461
|
+
issues: result.issues,
|
|
462
|
+
});
|
|
463
|
+
} catch (error) {
|
|
464
|
+
console.error(
|
|
465
|
+
`Failed to write token status: ${
|
|
466
|
+
error instanceof Error ? error.message : String(error)
|
|
467
|
+
}`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
419
471
|
const inTmuxNow = new Date();
|
|
420
472
|
|
|
421
473
|
try {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { Issue } from '../../../domain/entities/Issue';
|
|
5
|
+
import { DashboardRowFile, writeDashboardRow } from './dashboardRowWriter';
|
|
6
|
+
|
|
7
|
+
const readJson = (filePath: string): unknown =>
|
|
8
|
+
JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
|
|
9
|
+
|
|
10
|
+
const ASSIGNEE = 'HiromiShikata';
|
|
11
|
+
|
|
12
|
+
let issueCounter = 0;
|
|
13
|
+
const makeIssue = (overrides: Partial<Issue>): Issue => {
|
|
14
|
+
issueCounter += 1;
|
|
15
|
+
return {
|
|
16
|
+
nameWithOwner: 'demo/repo',
|
|
17
|
+
number: issueCounter,
|
|
18
|
+
title: `Issue ${issueCounter}`,
|
|
19
|
+
state: 'OPEN',
|
|
20
|
+
status: null,
|
|
21
|
+
story: null,
|
|
22
|
+
nextActionDate: null,
|
|
23
|
+
nextActionHour: null,
|
|
24
|
+
estimationMinutes: null,
|
|
25
|
+
dependedIssueUrls: [],
|
|
26
|
+
completionDate50PercentConfidence: null,
|
|
27
|
+
url: `https://github.com/demo/repo/issues/${issueCounter}`,
|
|
28
|
+
assignees: [ASSIGNEE],
|
|
29
|
+
labels: [],
|
|
30
|
+
org: 'demo',
|
|
31
|
+
repo: 'repo',
|
|
32
|
+
body: '',
|
|
33
|
+
itemId: `item-${issueCounter}`,
|
|
34
|
+
isPr: false,
|
|
35
|
+
isInProgress: false,
|
|
36
|
+
isClosed: false,
|
|
37
|
+
createdAt: new Date('2026-06-13T08:18:45.000Z'),
|
|
38
|
+
author: 'someone',
|
|
39
|
+
closingIssueReferenceUrls: [],
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe('writeDashboardRow', () => {
|
|
45
|
+
let dir: string;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
issueCounter = 0;
|
|
49
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashboard-row-'));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('writes a per-project row file with counts, pjcode and capturedAt', () => {
|
|
57
|
+
writeDashboardRow({
|
|
58
|
+
dashboardDataDir: dir,
|
|
59
|
+
pjcode: 'um',
|
|
60
|
+
assigneeLogin: ASSIGNEE,
|
|
61
|
+
issues: [
|
|
62
|
+
makeIssue({ status: 'Unread' }),
|
|
63
|
+
makeIssue({ status: 'Awaiting Quality Check' }),
|
|
64
|
+
],
|
|
65
|
+
generatedAt: '2026-06-26T12:00:00.000Z',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const written = readJson(path.join(dir, 'projects', 'um.json'));
|
|
69
|
+
const expected: DashboardRowFile = {
|
|
70
|
+
pjcode: 'um',
|
|
71
|
+
capturedAt: '2026-06-26T12:00:00.000Z',
|
|
72
|
+
unread: 1,
|
|
73
|
+
todo: 0,
|
|
74
|
+
qc: 1,
|
|
75
|
+
fail: 0,
|
|
76
|
+
pr: 0,
|
|
77
|
+
ws: 0,
|
|
78
|
+
dep: 0,
|
|
79
|
+
blocker: 0,
|
|
80
|
+
};
|
|
81
|
+
expect(written).toEqual(expected);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('is a no-op when dashboardDataDir is unset', () => {
|
|
85
|
+
writeDashboardRow({
|
|
86
|
+
dashboardDataDir: null,
|
|
87
|
+
pjcode: 'um',
|
|
88
|
+
assigneeLogin: ASSIGNEE,
|
|
89
|
+
issues: [makeIssue({ status: 'Unread' })],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(fs.readdirSync(dir)).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('is a no-op when pjcode or assigneeLogin is missing', () => {
|
|
96
|
+
writeDashboardRow({
|
|
97
|
+
dashboardDataDir: dir,
|
|
98
|
+
pjcode: null,
|
|
99
|
+
assigneeLogin: ASSIGNEE,
|
|
100
|
+
issues: [makeIssue({ status: 'Unread' })],
|
|
101
|
+
});
|
|
102
|
+
writeDashboardRow({
|
|
103
|
+
dashboardDataDir: dir,
|
|
104
|
+
pjcode: 'um',
|
|
105
|
+
assigneeLogin: null,
|
|
106
|
+
issues: [makeIssue({ status: 'Unread' })],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(fs.readdirSync(dir)).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import type { Issue } from '../../../domain/entities/Issue';
|
|
4
|
+
import {
|
|
5
|
+
DashboardRow,
|
|
6
|
+
GenerateDashboardRowUseCase,
|
|
7
|
+
} from '../../../domain/usecases/dashboard/GenerateDashboardRowUseCase';
|
|
8
|
+
|
|
9
|
+
export type DashboardRowWriterParams = {
|
|
10
|
+
dashboardDataDir: string | null | undefined;
|
|
11
|
+
pjcode: string | null | undefined;
|
|
12
|
+
assigneeLogin: string | null | undefined;
|
|
13
|
+
issues: Issue[];
|
|
14
|
+
generatedAt?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DashboardRowFile = DashboardRow & {
|
|
18
|
+
pjcode: string;
|
|
19
|
+
capturedAt: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const writeJsonAtomic = (filePath: string, data: unknown): void => {
|
|
23
|
+
const dir = path.dirname(filePath);
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
25
|
+
const tmpPath = `${filePath}.tmp`;
|
|
26
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data));
|
|
27
|
+
fs.renameSync(tmpPath, filePath);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const writeDashboardRow = (params: DashboardRowWriterParams): void => {
|
|
31
|
+
const { dashboardDataDir, pjcode, assigneeLogin, issues } = params;
|
|
32
|
+
if (!dashboardDataDir || !pjcode || !assigneeLogin) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const row: DashboardRow = new GenerateDashboardRowUseCase().run({
|
|
37
|
+
issues,
|
|
38
|
+
assigneeLogin,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const file: DashboardRowFile = {
|
|
42
|
+
pjcode,
|
|
43
|
+
capturedAt: params.generatedAt ?? new Date().toISOString(),
|
|
44
|
+
...row,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
writeJsonAtomic(
|
|
48
|
+
path.join(dashboardDataDir, 'projects', `${pjcode}.json`),
|
|
49
|
+
file,
|
|
50
|
+
);
|
|
51
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { ProcHostMetricsRepository } from '../../repositories/ProcHostMetricsRepository';
|
|
5
|
+
import { MachineStatusFile, writeMachineStatus } from './machineStatusWriter';
|
|
6
|
+
|
|
7
|
+
const readJson = (filePath: string): unknown =>
|
|
8
|
+
JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
|
|
9
|
+
|
|
10
|
+
const buildHostMetricsRepository = (
|
|
11
|
+
procDirectory: string,
|
|
12
|
+
): {
|
|
13
|
+
repository: ProcHostMetricsRepository;
|
|
14
|
+
} => {
|
|
15
|
+
fs.writeFileSync(
|
|
16
|
+
path.join(procDirectory, 'meminfo'),
|
|
17
|
+
'MemTotal: 1000 kB\nMemAvailable: 450 kB\n',
|
|
18
|
+
);
|
|
19
|
+
fs.writeFileSync(
|
|
20
|
+
path.join(procDirectory, 'loadavg'),
|
|
21
|
+
'1.50 0.80 0.40 1/100 200\n',
|
|
22
|
+
);
|
|
23
|
+
const statPath = path.join(procDirectory, 'stat');
|
|
24
|
+
fs.writeFileSync(statPath, 'cpu 600 0 0 400 0 0 0 0 0 0\n');
|
|
25
|
+
const sleep = async (): Promise<void> => {
|
|
26
|
+
fs.writeFileSync(statPath, 'cpu 660 0 0 440 0 0 0 0 0 0\n');
|
|
27
|
+
};
|
|
28
|
+
return { repository: new ProcHostMetricsRepository(procDirectory, sleep) };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('writeMachineStatus', () => {
|
|
32
|
+
let dir: string;
|
|
33
|
+
let procDirectory: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-status-'));
|
|
37
|
+
procDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-proc-'));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
42
|
+
fs.rmSync(procDirectory, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('writes machine-status.json with mem, cpu, load and cycle minutes', async () => {
|
|
46
|
+
const { repository } = buildHostMetricsRepository(procDirectory);
|
|
47
|
+
const cacheDir = path.join(dir, 'allIssues-PVT');
|
|
48
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
49
|
+
const newer = path.join(cacheDir, 'a.json');
|
|
50
|
+
const older = path.join(cacheDir, 'b.json');
|
|
51
|
+
fs.writeFileSync(older, '[]');
|
|
52
|
+
fs.writeFileSync(newer, '[]');
|
|
53
|
+
const base = Date.now();
|
|
54
|
+
fs.utimesSync(older, new Date(base - 900000), new Date(base - 900000));
|
|
55
|
+
fs.utimesSync(newer, new Date(base), new Date(base));
|
|
56
|
+
|
|
57
|
+
await writeMachineStatus({
|
|
58
|
+
dashboardDataDir: dir,
|
|
59
|
+
allIssuesCacheDir: cacheDir,
|
|
60
|
+
hostMetricsRepository: repository,
|
|
61
|
+
now: new Date('2026-06-26T12:00:00.000Z'),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const written = readJson(path.join(dir, 'machine-status.json'));
|
|
65
|
+
const expected: MachineStatusFile = {
|
|
66
|
+
memPct: 55,
|
|
67
|
+
cpuPct: 60,
|
|
68
|
+
load: [1.5, 0.8, 0.4],
|
|
69
|
+
cycleMinutes: 15,
|
|
70
|
+
capturedAt: '2026-06-26T12:00:00.000Z',
|
|
71
|
+
};
|
|
72
|
+
expect(written).toEqual(expected);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('writes cycleMinutes null when fewer than two cache files exist', async () => {
|
|
76
|
+
const { repository } = buildHostMetricsRepository(procDirectory);
|
|
77
|
+
const cacheDir = path.join(dir, 'allIssues-PVT');
|
|
78
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
79
|
+
fs.writeFileSync(path.join(cacheDir, 'only.json'), '[]');
|
|
80
|
+
|
|
81
|
+
await writeMachineStatus({
|
|
82
|
+
dashboardDataDir: dir,
|
|
83
|
+
allIssuesCacheDir: cacheDir,
|
|
84
|
+
hostMetricsRepository: repository,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const written = readJson(path.join(dir, 'machine-status.json'));
|
|
88
|
+
expect(written).toMatchObject({ cycleMinutes: null });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('is a no-op when dashboardDataDir is unset', async () => {
|
|
92
|
+
const { repository } = buildHostMetricsRepository(procDirectory);
|
|
93
|
+
await writeMachineStatus({
|
|
94
|
+
dashboardDataDir: null,
|
|
95
|
+
allIssuesCacheDir: null,
|
|
96
|
+
hostMetricsRepository: repository,
|
|
97
|
+
});
|
|
98
|
+
expect(fs.readdirSync(dir)).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import {
|
|
4
|
+
ProcHostMetricsRepository,
|
|
5
|
+
cycleMinutesFromMtimes,
|
|
6
|
+
} from '../../repositories/ProcHostMetricsRepository';
|
|
7
|
+
|
|
8
|
+
export type MachineStatusWriterParams = {
|
|
9
|
+
dashboardDataDir: string | null | undefined;
|
|
10
|
+
allIssuesCacheDir: string | null | undefined;
|
|
11
|
+
hostMetricsRepository?: ProcHostMetricsRepository;
|
|
12
|
+
now?: Date;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type MachineStatusFile = {
|
|
16
|
+
memPct: number;
|
|
17
|
+
cpuPct: number;
|
|
18
|
+
load: [number, number, number];
|
|
19
|
+
cycleMinutes: number | null;
|
|
20
|
+
capturedAt: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const writeJsonAtomic = (filePath: string, data: unknown): void => {
|
|
24
|
+
const dir = path.dirname(filePath);
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
26
|
+
const tmpPath = `${filePath}.tmp`;
|
|
27
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data));
|
|
28
|
+
fs.renameSync(tmpPath, filePath);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const cacheFileMtimesDescending = (allIssuesCacheDir: string): number[] => {
|
|
32
|
+
let entries: string[];
|
|
33
|
+
try {
|
|
34
|
+
entries = fs.readdirSync(allIssuesCacheDir);
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
return entries
|
|
39
|
+
.filter((entry) => entry.endsWith('.json'))
|
|
40
|
+
.map((entry) => {
|
|
41
|
+
try {
|
|
42
|
+
return fs.statSync(path.join(allIssuesCacheDir, entry)).mtimeMs / 1000;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
.filter((value): value is number => value !== null)
|
|
48
|
+
.sort((a, b) => b - a);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const writeMachineStatus = async (
|
|
52
|
+
params: MachineStatusWriterParams,
|
|
53
|
+
): Promise<void> => {
|
|
54
|
+
const { dashboardDataDir, allIssuesCacheDir } = params;
|
|
55
|
+
if (!dashboardDataDir) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const hostMetricsRepository =
|
|
60
|
+
params.hostMetricsRepository ?? new ProcHostMetricsRepository();
|
|
61
|
+
|
|
62
|
+
const memPct = hostMetricsRepository.readMemoryUsedPercent();
|
|
63
|
+
const cpuPct = await hostMetricsRepository.readCpuUsedPercent();
|
|
64
|
+
const load = hostMetricsRepository.readLoadAverages();
|
|
65
|
+
|
|
66
|
+
const cycleMinutes = allIssuesCacheDir
|
|
67
|
+
? cycleMinutesFromMtimes(cacheFileMtimesDescending(allIssuesCacheDir))
|
|
68
|
+
: null;
|
|
69
|
+
|
|
70
|
+
const file: MachineStatusFile = {
|
|
71
|
+
memPct,
|
|
72
|
+
cpuPct,
|
|
73
|
+
load: [load.oneMinute, load.fiveMinute, load.fifteenMinute],
|
|
74
|
+
cycleMinutes,
|
|
75
|
+
capturedAt: (params.now ?? new Date()).toISOString(),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
writeJsonAtomic(path.join(dashboardDataDir, 'machine-status.json'), file);
|
|
79
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { Issue } from '../../../domain/entities/Issue';
|
|
5
|
+
import { ClaudeInteractiveSession } from '../../../domain/usecases/adapter-interfaces/ClaudeInteractiveSessionRepository';
|
|
6
|
+
import { TakeOwnershipSpawn } from '../../../domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository';
|
|
7
|
+
import { RateLimitSnapshot } from '../../proxy/RateLimitCache';
|
|
8
|
+
import { TokenStatusFile, writeTokenStatus } from './tokenStatusWriter';
|
|
9
|
+
|
|
10
|
+
const readJson = (filePath: string): unknown =>
|
|
11
|
+
JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
|
|
12
|
+
|
|
13
|
+
const ASSIGNEE = 'HiromiShikata';
|
|
14
|
+
|
|
15
|
+
const baseSnapshot = (
|
|
16
|
+
overrides: Partial<RateLimitSnapshot> = {},
|
|
17
|
+
): RateLimitSnapshot => ({
|
|
18
|
+
fiveHourUtilization: 0.1,
|
|
19
|
+
fiveHourReset: 0,
|
|
20
|
+
sevenDayUtilization: 0.1,
|
|
21
|
+
sevenDayReset: 0,
|
|
22
|
+
blocked: false,
|
|
23
|
+
rejected: false,
|
|
24
|
+
unifiedRejected: false,
|
|
25
|
+
fiveHourRejected: false,
|
|
26
|
+
sevenDayRejected: false,
|
|
27
|
+
unifiedStatus: 'allowed',
|
|
28
|
+
overageDisabledReason: null,
|
|
29
|
+
modelWeeklyLimits: {},
|
|
30
|
+
lastUpdatedEpoch: 0,
|
|
31
|
+
blockedUntilEpoch: 0,
|
|
32
|
+
...overrides,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const makeIssue = (overrides: Partial<Issue>): Issue => ({
|
|
36
|
+
nameWithOwner: 'demo/repo',
|
|
37
|
+
number: 1,
|
|
38
|
+
title: 'Issue',
|
|
39
|
+
state: 'OPEN',
|
|
40
|
+
status: null,
|
|
41
|
+
story: null,
|
|
42
|
+
nextActionDate: null,
|
|
43
|
+
nextActionHour: null,
|
|
44
|
+
estimationMinutes: null,
|
|
45
|
+
dependedIssueUrls: [],
|
|
46
|
+
completionDate50PercentConfidence: null,
|
|
47
|
+
url: 'https://github.com/demo/repo/issues/1',
|
|
48
|
+
assignees: [ASSIGNEE],
|
|
49
|
+
labels: [],
|
|
50
|
+
org: 'demo',
|
|
51
|
+
repo: 'repo',
|
|
52
|
+
body: '',
|
|
53
|
+
itemId: 'item-1',
|
|
54
|
+
isPr: false,
|
|
55
|
+
isInProgress: false,
|
|
56
|
+
isClosed: false,
|
|
57
|
+
createdAt: new Date('2026-06-13T08:18:45.000Z'),
|
|
58
|
+
author: 'someone',
|
|
59
|
+
closingIssueReferenceUrls: [],
|
|
60
|
+
...overrides,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('writeTokenStatus', () => {
|
|
64
|
+
let dir: string;
|
|
65
|
+
let tokenListPath: string;
|
|
66
|
+
const now = new Date('2026-06-26T12:00:00.000Z');
|
|
67
|
+
const nowEpoch = Math.floor(now.getTime() / 1000);
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'token-status-'));
|
|
71
|
+
tokenListPath = path.join(dir, 'tokens.json');
|
|
72
|
+
fs.writeFileSync(
|
|
73
|
+
tokenListPath,
|
|
74
|
+
JSON.stringify([
|
|
75
|
+
{ name: 'alice', token: 'token-a' },
|
|
76
|
+
{ name: 'bob', token: 'token-b' },
|
|
77
|
+
]),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('writes token-status.json with per-token color, util, prep and hum', () => {
|
|
86
|
+
const interactiveSessions: ClaudeInteractiveSession[] = [
|
|
87
|
+
{
|
|
88
|
+
token: 'token-a',
|
|
89
|
+
sessionId: 'session-1',
|
|
90
|
+
issueUrl: 'https://github.com/demo/repo/issues/1',
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
const spawns: TakeOwnershipSpawn[] = [
|
|
94
|
+
{ token: 'token-a', logPath: '/logs-aw/x.log' },
|
|
95
|
+
{ token: 'token-a', logPath: '/logs-aw/x.log' },
|
|
96
|
+
{ token: 'token-a', logPath: '/logs-aw/y.log' },
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
writeTokenStatus({
|
|
100
|
+
dashboardDataDir: dir,
|
|
101
|
+
tokenListJsonPath: tokenListPath,
|
|
102
|
+
issues: [
|
|
103
|
+
makeIssue({
|
|
104
|
+
status: 'In Tmux by human',
|
|
105
|
+
url: 'https://github.com/demo/repo/issues/1',
|
|
106
|
+
}),
|
|
107
|
+
],
|
|
108
|
+
now,
|
|
109
|
+
readSnapshot: (token) =>
|
|
110
|
+
token === 'token-a'
|
|
111
|
+
? baseSnapshot({
|
|
112
|
+
fiveHourUtilization: 0.42,
|
|
113
|
+
fiveHourReset: nowEpoch + 3600,
|
|
114
|
+
sevenDayUtilization: 0.5,
|
|
115
|
+
sevenDayReset: nowEpoch + 86400,
|
|
116
|
+
})
|
|
117
|
+
: null,
|
|
118
|
+
interactiveSessionRepository: {
|
|
119
|
+
listInteractiveSessions: () => interactiveSessions,
|
|
120
|
+
},
|
|
121
|
+
spawnRepository: { listSpawns: () => spawns },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const written = readJson(path.join(dir, 'token-status.json'));
|
|
125
|
+
const expected: TokenStatusFile = {
|
|
126
|
+
capturedAt: '2026-06-26T12:00:00.000Z',
|
|
127
|
+
tokens: [
|
|
128
|
+
{
|
|
129
|
+
name: 'alice',
|
|
130
|
+
fiveHourUtilizationPercent: 42,
|
|
131
|
+
fiveHourResetSeconds: 3600,
|
|
132
|
+
sevenDayUtilizationPercent: 50,
|
|
133
|
+
sevenDayResetSeconds: 86400,
|
|
134
|
+
color: 'G',
|
|
135
|
+
prep: 2,
|
|
136
|
+
hum: 1,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'bob',
|
|
140
|
+
fiveHourUtilizationPercent: null,
|
|
141
|
+
fiveHourResetSeconds: null,
|
|
142
|
+
sevenDayUtilizationPercent: null,
|
|
143
|
+
sevenDayResetSeconds: null,
|
|
144
|
+
color: 'Y',
|
|
145
|
+
prep: 0,
|
|
146
|
+
hum: 0,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
expect(written).toEqual(expected);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('is a no-op when dashboardDataDir is unset', () => {
|
|
154
|
+
writeTokenStatus({
|
|
155
|
+
dashboardDataDir: null,
|
|
156
|
+
tokenListJsonPath: tokenListPath,
|
|
157
|
+
issues: [],
|
|
158
|
+
readSnapshot: () => null,
|
|
159
|
+
interactiveSessionRepository: { listInteractiveSessions: () => [] },
|
|
160
|
+
spawnRepository: { listSpawns: () => [] },
|
|
161
|
+
});
|
|
162
|
+
expect(fs.existsSync(path.join(dir, 'token-status.json'))).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('is a no-op when the token list path is unset', () => {
|
|
166
|
+
writeTokenStatus({
|
|
167
|
+
dashboardDataDir: dir,
|
|
168
|
+
tokenListJsonPath: null,
|
|
169
|
+
issues: [],
|
|
170
|
+
readSnapshot: () => null,
|
|
171
|
+
interactiveSessionRepository: { listInteractiveSessions: () => [] },
|
|
172
|
+
spawnRepository: { listSpawns: () => [] },
|
|
173
|
+
});
|
|
174
|
+
expect(fs.existsSync(path.join(dir, 'token-status.json'))).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
});
|