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.
Files changed (80) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +8 -7
  3. package/bin/adapter/entry-points/cli/index.js +21 -13
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/{consoleServer.js → webServer.js} +11 -11
  6. package/bin/adapter/entry-points/console/webServer.js.map +1 -0
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +41 -2
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/dashboardRowWriter.js +34 -0
  10. package/bin/adapter/entry-points/handlers/dashboardRowWriter.js.map +1 -0
  11. package/bin/adapter/entry-points/handlers/machineStatusWriter.js +60 -0
  12. package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -0
  13. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +98 -0
  14. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -0
  15. package/bin/adapter/proxy/RateLimitCache.js +3 -0
  16. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  17. package/bin/adapter/repositories/ProcHostMetricsRepository.js +136 -0
  18. package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -0
  19. package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js +131 -0
  20. package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js.map +1 -0
  21. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +93 -8
  22. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  23. package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js +3 -0
  24. package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js.map +1 -0
  25. package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js +38 -0
  26. package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js.map +1 -0
  27. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +115 -0
  28. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -0
  29. package/package.json +1 -1
  30. package/src/adapter/entry-points/cli/index.test.ts +74 -21
  31. package/src/adapter/entry-points/cli/index.ts +146 -136
  32. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -2
  33. package/src/adapter/entry-points/console/{consoleServer.test.ts → webServer.test.ts} +32 -32
  34. package/src/adapter/entry-points/console/{consoleServer.ts → webServer.ts} +15 -17
  35. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +52 -0
  36. package/src/adapter/entry-points/handlers/dashboardRowWriter.test.ts +111 -0
  37. package/src/adapter/entry-points/handlers/dashboardRowWriter.ts +51 -0
  38. package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +100 -0
  39. package/src/adapter/entry-points/handlers/machineStatusWriter.ts +79 -0
  40. package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +176 -0
  41. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +139 -0
  42. package/src/adapter/proxy/RateLimitCache.test.ts +32 -0
  43. package/src/adapter/proxy/RateLimitCache.ts +6 -0
  44. package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +135 -0
  45. package/src/adapter/repositories/ProcHostMetricsRepository.ts +136 -0
  46. package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.test.ts +130 -0
  47. package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.ts +118 -0
  48. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +267 -13
  49. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +140 -47
  50. package/src/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.ts +8 -0
  51. package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.test.ts +159 -0
  52. package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.ts +72 -0
  53. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +209 -0
  54. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +195 -0
  55. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  56. package/types/adapter/entry-points/console/{consoleServer.d.ts → webServer.d.ts} +7 -7
  57. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -0
  58. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  59. package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts +15 -0
  60. package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts.map +1 -0
  61. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +16 -0
  62. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -0
  63. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts +21 -0
  64. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -0
  65. package/types/adapter/proxy/RateLimitCache.d.ts +2 -0
  66. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  67. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +23 -0
  68. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -0
  69. package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts +11 -0
  70. package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts.map +1 -0
  71. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +6 -1
  72. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
  73. package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts +8 -0
  74. package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts.map +1 -0
  75. package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts +19 -0
  76. package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts.map +1 -0
  77. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +53 -0
  78. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -0
  79. package/bin/adapter/entry-points/console/consoleServer.js.map +0 -1
  80. 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 DEFAULT_CONSOLE_PORT = 9981;
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 ConsoleServerOptions = {
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: ConsoleServerOptions,
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: ConsoleServerOptions,
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: ConsoleServerOptions,
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: ConsoleServerOptions,
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: ConsoleServerOptions,
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 handleConsoleRequest = async (
529
- options: ConsoleServerOptions,
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 createConsoleServer = (
628
- options: ConsoleServerOptions,
629
- ): http.Server =>
627
+ export const createWebServer = (options: WebServerOptions): http.Server =>
630
628
  http.createServer((request, response) => {
631
- handleConsoleRequest(options, request, response).catch((error) => {
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 StartConsoleServerOptions = ConsoleServerOptions & {
635
+ export type StartWebServerOptions = WebServerOptions & {
638
636
  port: number;
639
637
  };
640
638
 
641
- export const startConsoleServer = (
642
- options: StartConsoleServerOptions,
639
+ export const startWebServer = (
640
+ options: StartWebServerOptions,
643
641
  ): Promise<http.Server> =>
644
642
  new Promise((resolve, reject) => {
645
- const server = createConsoleServer(options);
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
+ });