@token-dashboard/codex-usage-uploader 0.1.1 → 0.1.3
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/README.md +28 -5
- package/package.json +10 -2
- package/src/cli.js +175 -47
- package/src/collector.js +66 -73
- package/src/constants.js +9 -0
- package/src/install.js +49 -0
- package/src/launchd.js +22 -3
- package/src/local-usage.js +342 -0
- package/src/runtime-config.js +32 -4
- package/src/utils.js +15 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
- 后台常驻运行,增量扫描本地 Codex session 数据
|
|
6
6
|
- 首次安装自动补采全量历史
|
|
7
|
-
- macOS launchd
|
|
7
|
+
- macOS launchd 托管,登录自启动可选
|
|
8
8
|
|
|
9
9
|
> 仅支持 macOS,需要 Node.js >= 22.13.0。
|
|
10
10
|
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
npx @token-dashboard/codex-usage-uploader init
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
安装过程中会自动读取 Codex 登录态(`~/.codex/auth.json
|
|
19
|
+
安装过程中会自动读取 Codex 登录态(`~/.codex/auth.json`)获取身份信息。如果读取不到邮箱,会在终端交互式提示你手动填写;同时会询问是否在这台 Mac 上启用登录自启动。
|
|
20
20
|
|
|
21
21
|
安装完成后,`codex-usage-uploader` 命令会被写入 `~/bin/`,后续可直接使用。
|
|
22
22
|
|
|
@@ -28,7 +28,7 @@ npx @token-dashboard/codex-usage-uploader init
|
|
|
28
28
|
codex-usage-uploader init
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
首次运行时完成安装与配置;重复运行会升级本地版本并重启后台服务,同时可重新选择是否启用登录自启动。后端地址默认为 `http://101.126.66.51:8086`,如需自定义可通过 `--backend-url` 指定。
|
|
32
32
|
|
|
33
33
|
### 绑定身份
|
|
34
34
|
|
|
@@ -50,6 +50,24 @@ codex-usage-uploader bind --email you@company.com --yes
|
|
|
50
50
|
| `codex-usage-uploader logs` | 查看服务日志(默认最近 100 行) |
|
|
51
51
|
| `codex-usage-uploader logs --lines 500` | 查看更多日志 |
|
|
52
52
|
|
|
53
|
+
### 查看本机 token 用量
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
codex-usage-uploader usage
|
|
57
|
+
codex-usage-uploader usage --period today
|
|
58
|
+
codex-usage-uploader usage --period 7d
|
|
59
|
+
codex-usage-uploader usage --from 2026-04-01 --to 2026-04-17
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`usage` 是一个纯本地只读命令,不依赖后端查询,也不要求先执行 `init`。它会重新扫描当前机器上的 Codex rollout 文件,默认同时包含:
|
|
63
|
+
|
|
64
|
+
- `~/.codex/sessions`
|
|
65
|
+
- 同级的 `~/.codex/archived_sessions`
|
|
66
|
+
|
|
67
|
+
默认输出是一张纯文本表格,列为 `Date`、`Models`、`Input`、`Output`、`Reasoning`、`Cache Read`、`Total Tokens`,最后一行固定为 `Total`。默认 `--period=all` 时,表格覆盖全历史所有有 token 使用的日期。
|
|
68
|
+
|
|
69
|
+
统计口径与现有 uploader/后端保持一致,使用 `token_count` 事件中的 `last_*` token 字段,而不是累计 `total_*` 字段。切天时区使用当前机器时区。
|
|
70
|
+
|
|
53
71
|
### 清除本地状态
|
|
54
72
|
|
|
55
73
|
```bash
|
|
@@ -81,16 +99,20 @@ codex-usage-uploader uninstall
|
|
|
81
99
|
| `--employee-name <name>` | 绑定姓名 |
|
|
82
100
|
| `--employee-id <id>` | 绑定工号 |
|
|
83
101
|
| `--interval <seconds>` | 扫描间隔秒数(默认 30) |
|
|
102
|
+
| `--period <value>` | `usage` 时间范围:`today`、`7d`、`30d`、`all` |
|
|
103
|
+
| `--from <date>` | `usage` 开始日期(`YYYY-MM-DD`) |
|
|
104
|
+
| `--to <date>` | `usage` 结束日期(`YYYY-MM-DD`) |
|
|
84
105
|
| `--yes` | 跳过所有交互确认 |
|
|
85
106
|
| `--lines <n>` | `logs` 命令输出行数(默认 100) |
|
|
86
107
|
| `-h, --help` | 显示帮助 |
|
|
87
108
|
|
|
88
109
|
## 工作原理
|
|
89
110
|
|
|
90
|
-
1. **init** 将包安装到 `~/.codex-usage-uploader/`,绑定身份后前台执行一次全量历史补采,完成后启动 macOS launchd
|
|
111
|
+
1. **init** 将包安装到 `~/.codex-usage-uploader/`,绑定身份后前台执行一次全量历史补采,完成后启动 macOS launchd 后台服务,并按交互选择决定是否启用登录自启动。
|
|
91
112
|
2. 后台服务每 30 秒(可通过 `--interval` 调整)增量扫描 `~/.codex/sessions/` 下的 rollout 文件。
|
|
92
113
|
3. 扫描到的 token 用量数据暂存在本地 SQLite 队列中,满足条件后自动封批上传至后端。
|
|
93
|
-
4.
|
|
114
|
+
4. `usage` 命令会按需重扫本地 `sessions + archived_sessions`,直接在终端计算并展示本机 token 用量,不写入本地状态库,也不访问后端。
|
|
115
|
+
5. 所有数据在上报前已做去敏处理,仅包含 session 元信息、turn 上下文摘要和 token 计数。
|
|
94
116
|
|
|
95
117
|
## 本地文件
|
|
96
118
|
|
|
@@ -99,6 +121,7 @@ codex-usage-uploader uninstall
|
|
|
99
121
|
| `~/.codex-usage-uploader/config.json` | 运行配置 |
|
|
100
122
|
| `~/.codex-usage-uploader/state.sqlite` | 本地状态数据库 |
|
|
101
123
|
| `~/.codex-usage-uploader/logs/` | 服务日志 |
|
|
124
|
+
| `~/.codex-usage-uploader/launchd/` | launchd service plist |
|
|
102
125
|
|
|
103
126
|
## License
|
|
104
127
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@token-dashboard/codex-usage-uploader",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Codex 用量上报 CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,15 @@
|
|
|
12
12
|
"README.md"
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
|
-
"
|
|
15
|
+
"init": "node bin/codex-usage-uploader.js init --backend-url http://localhost:8086",
|
|
16
|
+
"start": "node bin/codex-usage-uploader.js start",
|
|
17
|
+
"stop": "node bin/codex-usage-uploader.js stop",
|
|
18
|
+
"restart": "node bin/codex-usage-uploader.js restart",
|
|
19
|
+
"status": "node bin/codex-usage-uploader.js status",
|
|
20
|
+
"usage": "node bin/codex-usage-uploader.js usage",
|
|
21
|
+
"logs": "node bin/codex-usage-uploader.js logs",
|
|
22
|
+
"clear": "node bin/codex-usage-uploader.js clear",
|
|
23
|
+
"uninstall": "node bin/codex-usage-uploader.js uninstall"
|
|
16
24
|
},
|
|
17
25
|
"engines": {
|
|
18
26
|
"node": ">=22.13.0"
|
package/src/cli.js
CHANGED
|
@@ -3,9 +3,10 @@ import path from 'node:path';
|
|
|
3
3
|
import { CodexUsageUploader } from './collector.js';
|
|
4
4
|
import { identityIsBound, promptConfirm } from './auth.js';
|
|
5
5
|
import { CLI_NAME, DEFAULT_BACKEND_URL, DEFAULT_CONFIG_FILE, PRODUCT_NAME } from './constants.js';
|
|
6
|
-
import { findPackageRoot, installCurrentPackage } from './install.js';
|
|
6
|
+
import { ensurePathInShellConfigs, findPackageRoot, installCurrentPackage, removePathFromShellConfigs } from './install.js';
|
|
7
7
|
import { LaunchdServiceManager } from './launchd.js';
|
|
8
|
-
import {
|
|
8
|
+
import { collectLocalUsage, getLocalTimeZone } from './local-usage.js';
|
|
9
|
+
import { formatStatusOutput, mergeRuntimeConfig, saveRuntimeConfig } from './runtime-config.js';
|
|
9
10
|
import { StateDb } from './state-db.js';
|
|
10
11
|
|
|
11
12
|
const HELP_TEXT = `${PRODUCT_NAME}
|
|
@@ -18,6 +19,7 @@ Usage:
|
|
|
18
19
|
${CLI_NAME} stop
|
|
19
20
|
${CLI_NAME} restart
|
|
20
21
|
${CLI_NAME} status
|
|
22
|
+
${CLI_NAME} usage [--period <today|7d|30d|all>] [--from <date>] [--to <date>]
|
|
21
23
|
${CLI_NAME} logs [--lines 100]
|
|
22
24
|
${CLI_NAME} uninstall
|
|
23
25
|
|
|
@@ -33,6 +35,9 @@ Common options:
|
|
|
33
35
|
--employee-name <name> Bind employee name
|
|
34
36
|
--device-id <id> Override device ID
|
|
35
37
|
--hostname <name> Override hostname
|
|
38
|
+
--period <value> Usage range: today, 7d, 30d, or all
|
|
39
|
+
--from <date> Usage range start date (YYYY-MM-DD)
|
|
40
|
+
--to <date> Usage range end date (YYYY-MM-DD)
|
|
36
41
|
--yes Skip interactive prompts
|
|
37
42
|
--package-spec <spec> npm install spec used by init
|
|
38
43
|
--lines <n> Number of log lines for service logs
|
|
@@ -50,6 +55,9 @@ class CliOperationalError extends Error {
|
|
|
50
55
|
export const cliDeps = {
|
|
51
56
|
findPackageRoot,
|
|
52
57
|
installCurrentPackage,
|
|
58
|
+
ensurePathInShellConfigs,
|
|
59
|
+
removePathFromShellConfigs,
|
|
60
|
+
promptConfirm,
|
|
53
61
|
createUploader(runtime) {
|
|
54
62
|
return new CodexUsageUploader({
|
|
55
63
|
sessionsDir: runtime.sessionsDir,
|
|
@@ -57,6 +65,7 @@ export const cliDeps = {
|
|
|
57
65
|
backendUrl: runtime.backendUrl,
|
|
58
66
|
intervalSeconds: runtime.intervalSeconds,
|
|
59
67
|
codexAuthPath: runtime.codexAuthPath,
|
|
68
|
+
persistentCollectorIdPath: runtime.persistentCollectorIdPath,
|
|
60
69
|
});
|
|
61
70
|
},
|
|
62
71
|
createServiceManager(runtime) {
|
|
@@ -102,6 +111,9 @@ export function parseCliArgs(argv) {
|
|
|
102
111
|
'--employee-name',
|
|
103
112
|
'--device-id',
|
|
104
113
|
'--hostname',
|
|
114
|
+
'--period',
|
|
115
|
+
'--from',
|
|
116
|
+
'--to',
|
|
105
117
|
'--package-spec',
|
|
106
118
|
'--lines',
|
|
107
119
|
]);
|
|
@@ -158,6 +170,15 @@ function assignOption(options, key, value) {
|
|
|
158
170
|
case '--hostname':
|
|
159
171
|
options.hostname = value;
|
|
160
172
|
break;
|
|
173
|
+
case '--period':
|
|
174
|
+
options.period = value;
|
|
175
|
+
break;
|
|
176
|
+
case '--from':
|
|
177
|
+
options.from = value;
|
|
178
|
+
break;
|
|
179
|
+
case '--to':
|
|
180
|
+
options.to = value;
|
|
181
|
+
break;
|
|
161
182
|
case '--package-spec':
|
|
162
183
|
options.packageSpec = value;
|
|
163
184
|
break;
|
|
@@ -257,44 +278,21 @@ function formatDuration(durationMs) {
|
|
|
257
278
|
function printCatchUpProgress(event) {
|
|
258
279
|
switch (event.phase) {
|
|
259
280
|
case 'start':
|
|
260
|
-
console.log(
|
|
261
|
-
`[catch-up] start files=${event.totalFiles} queued_batches=${
|
|
262
|
-
event.bufferingBatchCount +
|
|
263
|
-
event.pendingBatchCount +
|
|
264
|
-
event.retryingBatchCount
|
|
265
|
-
} queued_events=${event.queuedEvents}`,
|
|
266
|
-
);
|
|
281
|
+
console.log(`[scan] start files=${event.totalFiles}`);
|
|
267
282
|
return;
|
|
268
283
|
case 'file':
|
|
269
284
|
console.log(
|
|
270
|
-
`[
|
|
271
|
-
event.bufferingBatchCount +
|
|
272
|
-
event.pendingBatchCount +
|
|
273
|
-
event.retryingBatchCount
|
|
274
|
-
} queued_events=${event.queuedEvents}`,
|
|
275
|
-
);
|
|
276
|
-
return;
|
|
277
|
-
case 'upload':
|
|
278
|
-
console.log(
|
|
279
|
-
`[catch-up] uploaded_batches=${event.batchesUploaded} last_batch=${event.batchKey} remaining_batches=${
|
|
280
|
-
event.bufferingBatchCount +
|
|
281
|
-
event.pendingBatchCount +
|
|
282
|
-
event.retryingBatchCount
|
|
283
|
-
} remaining_events=${event.queuedEvents}`,
|
|
285
|
+
`[scan] file ${event.filesProcessed}/${event.totalFiles} current=${event.file} events=${event.eventsParsed}`,
|
|
284
286
|
);
|
|
285
287
|
return;
|
|
286
288
|
case 'done':
|
|
287
289
|
console.log(
|
|
288
|
-
`[
|
|
290
|
+
`[scan] complete files=${event.filesProcessed}/${event.totalFiles} events=${event.eventsParsed} pending_batches=${event.pendingBatches} duration=${formatDuration(event.durationMs)}`,
|
|
289
291
|
);
|
|
290
292
|
return;
|
|
291
293
|
case 'error':
|
|
292
294
|
console.error(
|
|
293
|
-
`[
|
|
294
|
-
event.bufferingBatchCount +
|
|
295
|
-
event.pendingBatchCount +
|
|
296
|
-
event.retryingBatchCount
|
|
297
|
-
} remaining_events=${event.queuedEvents}`,
|
|
295
|
+
`[scan] failed stage=${event.stage} message=${event.message}`,
|
|
298
296
|
);
|
|
299
297
|
return;
|
|
300
298
|
}
|
|
@@ -308,17 +306,21 @@ function wrapCatchUpFailure(error) {
|
|
|
308
306
|
);
|
|
309
307
|
}
|
|
310
308
|
|
|
311
|
-
function
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
console.log(
|
|
317
|
-
`Note: ${linkDir} is not in your PATH. To use \`${CLI_NAME}\` directly, run:`,
|
|
309
|
+
function wrapUsageFailure(error) {
|
|
310
|
+
return new CliOperationalError(
|
|
311
|
+
`Usage query failed: ${
|
|
312
|
+
error instanceof Error ? error.message : String(error)
|
|
313
|
+
}`,
|
|
318
314
|
);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function resolveAutoStartOnLogin(runtime, options) {
|
|
318
|
+
if (options.yes) {
|
|
319
|
+
return runtime.autoStartOnLogin;
|
|
320
|
+
}
|
|
321
|
+
return cliDeps.promptConfirm(
|
|
322
|
+
'Start automatically when you log in on this Mac?',
|
|
323
|
+
runtime.autoStartOnLogin,
|
|
322
324
|
);
|
|
323
325
|
}
|
|
324
326
|
|
|
@@ -340,7 +342,7 @@ async function runInit(options) {
|
|
|
340
342
|
nodePath: process.execPath,
|
|
341
343
|
});
|
|
342
344
|
|
|
343
|
-
|
|
345
|
+
let manager = cliDeps.createServiceManager(runtime);
|
|
344
346
|
manager.stop();
|
|
345
347
|
|
|
346
348
|
const uploader = cliDeps.createUploader(runtime);
|
|
@@ -351,25 +353,137 @@ async function runInit(options) {
|
|
|
351
353
|
console.log(`Install root: ${runtime.installRoot}`);
|
|
352
354
|
console.log(`Config file: ${runtime.configFile}`);
|
|
353
355
|
printIdentity(identity);
|
|
354
|
-
console.log('
|
|
356
|
+
console.log('Scanning local Codex sessions...');
|
|
355
357
|
|
|
358
|
+
let scanResult;
|
|
356
359
|
try {
|
|
357
|
-
await uploader.runForegroundCatchUp({
|
|
360
|
+
scanResult = await uploader.runForegroundCatchUp({
|
|
358
361
|
onProgress: printCatchUpProgress,
|
|
359
362
|
});
|
|
360
363
|
} catch (error) {
|
|
361
364
|
throw wrapCatchUpFailure(error);
|
|
362
365
|
}
|
|
363
366
|
|
|
367
|
+
runtime.autoStartOnLogin = await resolveAutoStartOnLogin(runtime, options);
|
|
368
|
+
runtime = saveRuntimeConfig(runtime);
|
|
369
|
+
manager = cliDeps.createServiceManager(runtime);
|
|
364
370
|
manager.start();
|
|
365
371
|
console.log(`${PRODUCT_NAME} initialized and started.`);
|
|
372
|
+
console.log(
|
|
373
|
+
runtime.autoStartOnLogin
|
|
374
|
+
? 'Login auto-start is enabled.'
|
|
375
|
+
: 'Login auto-start is disabled for future logins.',
|
|
376
|
+
);
|
|
377
|
+
if (scanResult.pendingBatches > 0) {
|
|
378
|
+
console.log(
|
|
379
|
+
`Background service is uploading ${scanResult.pendingBatches} batch(es) with ${scanResult.pendingEvents} event(s).`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
366
382
|
console.log(`Use \`${CLI_NAME} status\` to check the local service.`);
|
|
367
|
-
|
|
383
|
+
const linkDir = path.dirname(runtime.homeBinLink);
|
|
384
|
+
const pathDirs = (process.env.PATH || '').split(path.delimiter);
|
|
385
|
+
if (!pathDirs.includes(linkDir)) {
|
|
386
|
+
cliDeps.ensurePathInShellConfigs(linkDir);
|
|
387
|
+
console.log(`Open a new terminal to use \`${CLI_NAME}\` directly.`);
|
|
388
|
+
}
|
|
368
389
|
} finally {
|
|
369
390
|
uploader.close();
|
|
370
391
|
}
|
|
371
392
|
}
|
|
372
393
|
|
|
394
|
+
function formatCount(value) {
|
|
395
|
+
return Number(value ?? 0).toLocaleString('en-US');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function renderTable(headers, rows, alignments = []) {
|
|
399
|
+
const widths = headers.map((header, index) =>
|
|
400
|
+
Math.max(
|
|
401
|
+
header.length,
|
|
402
|
+
...rows.map((row) => String(row[index] ?? '').length),
|
|
403
|
+
),
|
|
404
|
+
);
|
|
405
|
+
const formatCell = (cell, index) => {
|
|
406
|
+
const text = String(cell ?? '');
|
|
407
|
+
return alignments[index] === 'right'
|
|
408
|
+
? text.padStart(widths[index])
|
|
409
|
+
: text.padEnd(widths[index]);
|
|
410
|
+
};
|
|
411
|
+
const formatRow = (row) =>
|
|
412
|
+
row
|
|
413
|
+
.map((cell, index) => formatCell(cell, index))
|
|
414
|
+
.join(' ')
|
|
415
|
+
.trimEnd();
|
|
416
|
+
return [
|
|
417
|
+
formatRow(headers),
|
|
418
|
+
...rows.map((row) => formatRow(row)),
|
|
419
|
+
].join('\n');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function formatUsageDate(dateString) {
|
|
423
|
+
const [year, month, day] = dateString.split('-').map(Number);
|
|
424
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
425
|
+
timeZone: 'UTC',
|
|
426
|
+
month: 'short',
|
|
427
|
+
day: 'numeric',
|
|
428
|
+
year: 'numeric',
|
|
429
|
+
}).format(new Date(Date.UTC(year, month - 1, day)));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function printUsageReport(report) {
|
|
433
|
+
if (report.tokenEventCount === 0) {
|
|
434
|
+
console.log('No local Codex usage found for selected range.');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const rows = report.days.map((row) => [
|
|
439
|
+
formatUsageDate(row.date),
|
|
440
|
+
row.models.join(', '),
|
|
441
|
+
formatCount(row.inputTokens),
|
|
442
|
+
formatCount(row.outputTokens),
|
|
443
|
+
formatCount(row.reasoningOutputTokens),
|
|
444
|
+
formatCount(row.cachedInputTokens),
|
|
445
|
+
formatCount(row.totalTokens),
|
|
446
|
+
]);
|
|
447
|
+
rows.push([
|
|
448
|
+
'Total',
|
|
449
|
+
'',
|
|
450
|
+
formatCount(report.summary.inputTokens),
|
|
451
|
+
formatCount(report.summary.outputTokens),
|
|
452
|
+
formatCount(report.summary.reasoningOutputTokens),
|
|
453
|
+
formatCount(report.summary.cachedInputTokens),
|
|
454
|
+
formatCount(report.summary.totalTokens),
|
|
455
|
+
]);
|
|
456
|
+
|
|
457
|
+
console.log(
|
|
458
|
+
renderTable(
|
|
459
|
+
['Date', 'Models', 'Input', 'Output', 'Reasoning', 'Cache Read', 'Total Tokens'],
|
|
460
|
+
rows,
|
|
461
|
+
['left', 'left', 'right', 'right', 'right', 'right', 'right'],
|
|
462
|
+
),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function runUsage(options) {
|
|
467
|
+
const runtime = mergeRuntimeConfig(options.configFile, runtimeOverrides(options));
|
|
468
|
+
|
|
469
|
+
console.log('Scanning local Codex usage...');
|
|
470
|
+
|
|
471
|
+
let report;
|
|
472
|
+
try {
|
|
473
|
+
report = await collectLocalUsage({
|
|
474
|
+
sessionsDir: runtime.sessionsDir,
|
|
475
|
+
period: options.period,
|
|
476
|
+
from: options.from,
|
|
477
|
+
to: options.to,
|
|
478
|
+
timeZone: getLocalTimeZone(),
|
|
479
|
+
});
|
|
480
|
+
} catch (error) {
|
|
481
|
+
throw wrapUsageFailure(error);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
printUsageReport(report);
|
|
485
|
+
}
|
|
486
|
+
|
|
373
487
|
async function runBind(options) {
|
|
374
488
|
const runtime = mergeRuntimeConfig(options.configFile, runtimeOverrides(options));
|
|
375
489
|
const uploader = cliDeps.createUploader(runtime);
|
|
@@ -403,6 +517,7 @@ async function runInternalWorker(options) {
|
|
|
403
517
|
function loadQueueStats(stateDbPath) {
|
|
404
518
|
if (!fs.existsSync(stateDbPath)) {
|
|
405
519
|
return {
|
|
520
|
+
collectorId: null,
|
|
406
521
|
bufferingBatchCount: 0,
|
|
407
522
|
pendingBatchCount: 0,
|
|
408
523
|
retryingBatchCount: 0,
|
|
@@ -414,7 +529,11 @@ function loadQueueStats(stateDbPath) {
|
|
|
414
529
|
}
|
|
415
530
|
const stateDb = new StateDb(stateDbPath);
|
|
416
531
|
try {
|
|
417
|
-
|
|
532
|
+
const identity = stateDb.getIdentity();
|
|
533
|
+
return {
|
|
534
|
+
collectorId: identity.collectorId ?? null,
|
|
535
|
+
...stateDb.getQueueStats(),
|
|
536
|
+
};
|
|
418
537
|
} finally {
|
|
419
538
|
stateDb.close();
|
|
420
539
|
}
|
|
@@ -429,10 +548,13 @@ function printStatus(runtime, status) {
|
|
|
429
548
|
['Config exists', payload.configExists ? 'yes' : 'no'],
|
|
430
549
|
['Loaded', payload.loaded ? 'yes' : 'no'],
|
|
431
550
|
['Running', payload.running ? 'yes' : 'no'],
|
|
551
|
+
['Auto start on login', payload.autoStartOnLogin ? 'yes' : 'no'],
|
|
432
552
|
['PID', payload.pid ?? '-'],
|
|
433
553
|
['State', payload.state ?? '-'],
|
|
434
554
|
['Last exit code', payload.lastExitCode ?? '-'],
|
|
435
|
-
['
|
|
555
|
+
['Collector ID', payload.collectorId ?? '-'],
|
|
556
|
+
['Upload URL', payload.backendUrl ? `${payload.backendUrl}/codex-usage/upload` : '-'],
|
|
557
|
+
['Register URL', payload.backendUrl ? `${payload.backendUrl}/codex-usage/collectors/register` : '-'],
|
|
436
558
|
['Interval', `${payload.intervalSeconds}s`],
|
|
437
559
|
['Buffering batches', payload.bufferingBatchCount],
|
|
438
560
|
['Pending batches', payload.pendingBatchCount],
|
|
@@ -450,7 +572,8 @@ function printStatus(runtime, status) {
|
|
|
450
572
|
['State DB', payload.stateDbPath],
|
|
451
573
|
['Stdout log', payload.stdoutLogPath],
|
|
452
574
|
['Stderr log', payload.stderrLogPath],
|
|
453
|
-
['
|
|
575
|
+
['Service plist', payload.plistPath],
|
|
576
|
+
['LaunchAgent plist', payload.launchAgentPath],
|
|
454
577
|
['Label', payload.label],
|
|
455
578
|
];
|
|
456
579
|
for (const [label, value] of lines) {
|
|
@@ -479,7 +602,7 @@ async function runClear(options) {
|
|
|
479
602
|
}
|
|
480
603
|
|
|
481
604
|
if (!options.yes) {
|
|
482
|
-
const confirmed = await promptConfirm(
|
|
605
|
+
const confirmed = await cliDeps.promptConfirm(
|
|
483
606
|
'Clear local backfill state and queued uploads?',
|
|
484
607
|
false,
|
|
485
608
|
);
|
|
@@ -550,6 +673,7 @@ function runLifecycleCommand(action, options) {
|
|
|
550
673
|
}
|
|
551
674
|
case 'uninstall':
|
|
552
675
|
manager.uninstall();
|
|
676
|
+
cliDeps.removePathFromShellConfigs();
|
|
553
677
|
fs.rmSync(runtime.installRoot, { recursive: true, force: true });
|
|
554
678
|
console.log(`${PRODUCT_NAME} uninstalled from ${runtime.installRoot}`);
|
|
555
679
|
return;
|
|
@@ -574,6 +698,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
574
698
|
'stop',
|
|
575
699
|
'restart',
|
|
576
700
|
'status',
|
|
701
|
+
'usage',
|
|
577
702
|
'logs',
|
|
578
703
|
'uninstall',
|
|
579
704
|
'run',
|
|
@@ -594,6 +719,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
594
719
|
case 'clear':
|
|
595
720
|
await runClear(options);
|
|
596
721
|
return 0;
|
|
722
|
+
case 'usage':
|
|
723
|
+
await runUsage(options);
|
|
724
|
+
return 0;
|
|
597
725
|
case 'run':
|
|
598
726
|
await runInternalWorker(options);
|
|
599
727
|
return 0;
|
package/src/collector.js
CHANGED
|
@@ -5,11 +5,12 @@ import { createInterface } from 'node:readline';
|
|
|
5
5
|
import { loadCodexAuthIdentity, identityIsBound, promptValue } from './auth.js';
|
|
6
6
|
import { RolloutParser } from './parser.js';
|
|
7
7
|
import { StateDb } from './state-db.js';
|
|
8
|
-
import { nowTs, sleep, stableStringify } from './utils.js';
|
|
8
|
+
import { nowTs, sleep, stableStringify, readPersistentCollectorId, writePersistentCollectorId } from './utils.js';
|
|
9
9
|
import {
|
|
10
10
|
ARCHIVED_SESSIONS_SOURCE_ROOT,
|
|
11
11
|
MAX_BATCH_BYTES,
|
|
12
12
|
MAX_EVENTS_PER_BATCH,
|
|
13
|
+
PERSISTENT_COLLECTOR_ID_PATH,
|
|
13
14
|
REGISTER_REFRESH_SECONDS,
|
|
14
15
|
SCAN_CHUNK_MAX_BYTES,
|
|
15
16
|
SCAN_CHUNK_MAX_EVENTS,
|
|
@@ -23,6 +24,7 @@ export class CodexUsageUploader {
|
|
|
23
24
|
backendUrl,
|
|
24
25
|
intervalSeconds,
|
|
25
26
|
codexAuthPath,
|
|
27
|
+
persistentCollectorIdPath = PERSISTENT_COLLECTOR_ID_PATH,
|
|
26
28
|
scanChunkMaxEvents = SCAN_CHUNK_MAX_EVENTS,
|
|
27
29
|
scanChunkMaxBytes = SCAN_CHUNK_MAX_BYTES,
|
|
28
30
|
}) {
|
|
@@ -31,6 +33,7 @@ export class CodexUsageUploader {
|
|
|
31
33
|
this.backendUrl = backendUrl?.replace(/\/+$/, '') || null;
|
|
32
34
|
this.intervalSeconds = intervalSeconds;
|
|
33
35
|
this.codexAuthPath = codexAuthPath;
|
|
36
|
+
this.persistentCollectorIdPath = persistentCollectorIdPath;
|
|
34
37
|
this.scanChunkMaxEvents = scanChunkMaxEvents;
|
|
35
38
|
this.scanChunkMaxBytes = scanChunkMaxBytes;
|
|
36
39
|
this.identity = this.ensureIdentity();
|
|
@@ -43,10 +46,12 @@ export class CodexUsageUploader {
|
|
|
43
46
|
ensureIdentity() {
|
|
44
47
|
const identity = this.stateDb.getIdentity();
|
|
45
48
|
if (!identity.collectorId) {
|
|
46
|
-
|
|
49
|
+
const persisted = readPersistentCollectorId(this.persistentCollectorIdPath);
|
|
50
|
+
identity.collectorId = persisted ?? `${Date.now().toString(16)}${Math.random()
|
|
47
51
|
.toString(16)
|
|
48
52
|
.slice(2, 14)}`;
|
|
49
53
|
}
|
|
54
|
+
writePersistentCollectorId(this.persistentCollectorIdPath, identity.collectorId);
|
|
50
55
|
if (!identity.deviceId) {
|
|
51
56
|
identity.deviceId = `${os.hostname()}-${Math.random()
|
|
52
57
|
.toString(16)
|
|
@@ -465,7 +470,7 @@ export class CodexUsageUploader {
|
|
|
465
470
|
const response = await fetch(`${this.backendUrl}${apiPath}`, {
|
|
466
471
|
method: 'POST',
|
|
467
472
|
headers: { 'Content-Type': 'application/json' },
|
|
468
|
-
body:
|
|
473
|
+
body: JSON.stringify(payload),
|
|
469
474
|
});
|
|
470
475
|
if (!response.ok) {
|
|
471
476
|
const text = await response.text();
|
|
@@ -489,7 +494,7 @@ export class CodexUsageUploader {
|
|
|
489
494
|
this.stateDb.setCheckpoint('last_register_at', String(nowTs()));
|
|
490
495
|
}
|
|
491
496
|
|
|
492
|
-
async flushPendingBatches({ failFast = false, onBatchUploaded } = {}) {
|
|
497
|
+
async flushPendingBatches({ failFast = false, concurrency = 5, onBatchUploaded } = {}) {
|
|
493
498
|
if (!this.backendUrl) {
|
|
494
499
|
return { uploadedBatches: 0, failedBatches: 0, lastError: null };
|
|
495
500
|
}
|
|
@@ -509,42 +514,70 @@ export class CodexUsageUploader {
|
|
|
509
514
|
let uploadedBatches = 0;
|
|
510
515
|
let failedBatches = 0;
|
|
511
516
|
let lastError = null;
|
|
517
|
+
const collectorBody = this.collectorRequestBody();
|
|
512
518
|
|
|
513
|
-
|
|
519
|
+
const rows = this.stateDb.iterDuePendingBatches();
|
|
520
|
+
|
|
521
|
+
const uploadOne = async (row) => {
|
|
514
522
|
const payload = this.sanitizeUploadPayload(JSON.parse(row.payload_json));
|
|
515
523
|
const requestBody = {
|
|
516
524
|
idempotencyKey: row.batch_key,
|
|
517
|
-
collector:
|
|
525
|
+
collector: collectorBody,
|
|
518
526
|
payloadSizeBytes: Number(row.payload_bytes),
|
|
519
527
|
sessions: payload.sessions ?? [],
|
|
520
528
|
turns: payload.turns ?? [],
|
|
521
529
|
events: payload.events ?? [],
|
|
522
530
|
};
|
|
531
|
+
await this.postJson('/codex-usage/upload', requestBody);
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const pending = new Set();
|
|
535
|
+
let rowIndex = 0;
|
|
536
|
+
|
|
537
|
+
const enqueue = () => {
|
|
538
|
+
while (pending.size < concurrency && rowIndex < rows.length) {
|
|
539
|
+
const row = rows[rowIndex++];
|
|
540
|
+
const task = uploadOne(row)
|
|
541
|
+
.then(() => {
|
|
542
|
+
this.stateDb.markBatchUploaded(row.id);
|
|
543
|
+
uploadedBatches += 1;
|
|
544
|
+
onBatchUploaded?.({ row, uploadedBatches });
|
|
545
|
+
})
|
|
546
|
+
.catch((error) => {
|
|
547
|
+
this.stateDb.markBatchFailed(
|
|
548
|
+
row.id,
|
|
549
|
+
Number(row.attempt_count) + 1,
|
|
550
|
+
error instanceof Error ? error.message : String(error),
|
|
551
|
+
);
|
|
552
|
+
failedBatches += 1;
|
|
553
|
+
lastError = error;
|
|
554
|
+
if (failFast) {
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
})
|
|
558
|
+
.finally(() => {
|
|
559
|
+
pending.delete(task);
|
|
560
|
+
});
|
|
561
|
+
pending.add(task);
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
enqueue();
|
|
566
|
+
while (pending.size > 0) {
|
|
523
567
|
try {
|
|
524
|
-
await
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
onBatchUploaded?.({
|
|
528
|
-
row,
|
|
529
|
-
uploadedBatches,
|
|
530
|
-
queueStats: this.getQueueStats(),
|
|
531
|
-
});
|
|
532
|
-
} catch (error) {
|
|
533
|
-
this.stateDb.markBatchFailed(
|
|
534
|
-
row.id,
|
|
535
|
-
Number(row.attempt_count) + 1,
|
|
536
|
-
error instanceof Error ? error.message : String(error),
|
|
537
|
-
);
|
|
538
|
-
failedBatches += 1;
|
|
539
|
-
lastError = error;
|
|
540
|
-
if (failFast) {
|
|
541
|
-
throw new Error(
|
|
542
|
-
`Upload batch ${row.batch_key} failed: ${
|
|
543
|
-
error instanceof Error ? error.message : String(error)
|
|
544
|
-
}`,
|
|
545
|
-
);
|
|
546
|
-
}
|
|
568
|
+
await Promise.race(pending);
|
|
569
|
+
} catch {
|
|
570
|
+
if (failFast) break;
|
|
547
571
|
}
|
|
572
|
+
enqueue();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (failFast && lastError) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
`Upload batch failed: ${
|
|
578
|
+
lastError instanceof Error ? lastError.message : String(lastError)
|
|
579
|
+
}`,
|
|
580
|
+
);
|
|
548
581
|
}
|
|
549
582
|
|
|
550
583
|
return { uploadedBatches, failedBatches, lastError };
|
|
@@ -578,15 +611,12 @@ export class CodexUsageUploader {
|
|
|
578
611
|
filesProcessed: 0,
|
|
579
612
|
eventsParsed: 0,
|
|
580
613
|
batchesQueued: 0,
|
|
581
|
-
batchesUploaded: 0,
|
|
582
|
-
...this.getQueueStats(),
|
|
583
614
|
});
|
|
584
615
|
|
|
585
616
|
let scanResult;
|
|
586
617
|
try {
|
|
587
618
|
scanResult = await this.scanSnapshotEntries(backfillSnapshot, {
|
|
588
619
|
onFileProcessed: ({ entry, filesProcessed, totals }) => {
|
|
589
|
-
const queueStats = this.getQueueStats();
|
|
590
620
|
onProgress?.({
|
|
591
621
|
phase: 'file',
|
|
592
622
|
file: entry.progressPath ?? entry.relpath,
|
|
@@ -594,8 +624,6 @@ export class CodexUsageUploader {
|
|
|
594
624
|
filesProcessed,
|
|
595
625
|
eventsParsed: totals.events,
|
|
596
626
|
batchesQueued: totals.batchesQueued,
|
|
597
|
-
batchesUploaded: 0,
|
|
598
|
-
...queueStats,
|
|
599
627
|
});
|
|
600
628
|
},
|
|
601
629
|
});
|
|
@@ -604,44 +632,13 @@ export class CodexUsageUploader {
|
|
|
604
632
|
phase: 'error',
|
|
605
633
|
stage: 'scan',
|
|
606
634
|
message: error instanceof Error ? error.message : String(error),
|
|
607
|
-
...this.getQueueStats(),
|
|
608
635
|
});
|
|
609
636
|
throw error;
|
|
610
637
|
}
|
|
611
638
|
|
|
612
639
|
scanResult.batchesQueued += this.stateDb.sealStaleBatches(true);
|
|
613
|
-
this.stateDb.markAllPendingDue();
|
|
614
640
|
|
|
615
|
-
|
|
616
|
-
try {
|
|
617
|
-
flushResult = await this.flushPendingBatches({
|
|
618
|
-
failFast: true,
|
|
619
|
-
onBatchUploaded: ({ row, uploadedBatches, queueStats }) => {
|
|
620
|
-
onProgress?.({
|
|
621
|
-
phase: 'upload',
|
|
622
|
-
batchKey: row.batch_key,
|
|
623
|
-
totalFiles,
|
|
624
|
-
filesProcessed: totalFiles,
|
|
625
|
-
eventsParsed: scanResult.events,
|
|
626
|
-
batchesQueued: scanResult.batchesQueued,
|
|
627
|
-
batchesUploaded: uploadedBatches,
|
|
628
|
-
...queueStats,
|
|
629
|
-
});
|
|
630
|
-
},
|
|
631
|
-
});
|
|
632
|
-
} catch (error) {
|
|
633
|
-
onProgress?.({
|
|
634
|
-
phase: 'error',
|
|
635
|
-
stage: 'upload',
|
|
636
|
-
message: error instanceof Error ? error.message : String(error),
|
|
637
|
-
totalFiles,
|
|
638
|
-
filesProcessed: totalFiles,
|
|
639
|
-
eventsParsed: scanResult.events,
|
|
640
|
-
batchesQueued: scanResult.batchesQueued,
|
|
641
|
-
...this.getQueueStats(),
|
|
642
|
-
});
|
|
643
|
-
throw error;
|
|
644
|
-
}
|
|
641
|
+
await this.ensureRemoteRegistration();
|
|
645
642
|
|
|
646
643
|
const queueStats = this.getQueueStats();
|
|
647
644
|
const result = {
|
|
@@ -651,14 +648,10 @@ export class CodexUsageUploader {
|
|
|
651
648
|
sessionsParsed: scanResult.sessions,
|
|
652
649
|
turnsParsed: scanResult.turns,
|
|
653
650
|
batchesQueued: scanResult.batchesQueued,
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
queueStats.pendingBatchCount +
|
|
658
|
-
queueStats.retryingBatchCount,
|
|
659
|
-
remainingQueuedEvents: queueStats.queuedEvents,
|
|
651
|
+
pendingBatches:
|
|
652
|
+
queueStats.pendingBatchCount + queueStats.retryingBatchCount,
|
|
653
|
+
pendingEvents: queueStats.queuedEvents,
|
|
660
654
|
durationMs: Date.now() - startedAt,
|
|
661
|
-
...queueStats,
|
|
662
655
|
};
|
|
663
656
|
|
|
664
657
|
onProgress?.({
|
package/src/constants.js
CHANGED
|
@@ -20,11 +20,20 @@ export const DEFAULT_LOCAL_BIN_PATH = path.join(DEFAULT_LOCAL_BIN_DIR, CLI_NAME)
|
|
|
20
20
|
export const DEFAULT_HOME_BIN_LINK = path.join(os.homedir(), 'bin', CLI_NAME);
|
|
21
21
|
export const DEFAULT_LAUNCHD_LABEL = 'com.token-dashboard.codex-usage-uploader';
|
|
22
22
|
export const DEFAULT_PLIST_PATH = path.join(
|
|
23
|
+
DEFAULT_INSTALL_ROOT,
|
|
24
|
+
'launchd',
|
|
25
|
+
`${DEFAULT_LAUNCHD_LABEL}.plist`,
|
|
26
|
+
);
|
|
27
|
+
export const DEFAULT_LAUNCH_AGENT_PATH = path.join(
|
|
23
28
|
os.homedir(),
|
|
24
29
|
'Library',
|
|
25
30
|
'LaunchAgents',
|
|
26
31
|
`${DEFAULT_LAUNCHD_LABEL}.plist`,
|
|
27
32
|
);
|
|
33
|
+
export const PERSISTENT_COLLECTOR_ID_PATH = path.join(
|
|
34
|
+
os.homedir(),
|
|
35
|
+
'.codex-usage-uploader-collector-id',
|
|
36
|
+
);
|
|
28
37
|
export const DEFAULT_BACKEND_URL = 'http://101.126.66.51:8086';
|
|
29
38
|
export const SCAN_CHUNK_MAX_EVENTS = 50;
|
|
30
39
|
export const SCAN_CHUNK_MAX_BYTES = 256 * 1024;
|
package/src/install.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { spawnSync } from 'node:child_process';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { CLI_NAME } from './constants.js';
|
|
6
7
|
import { saveRuntimeConfig } from './runtime-config.js';
|
|
7
8
|
|
|
9
|
+
const SHELL_CONFIG_MARKER = '# Added by codex-usage-uploader';
|
|
10
|
+
const SHELL_CONFIG_FILES = [
|
|
11
|
+
path.join(os.homedir(), '.zshrc'),
|
|
12
|
+
path.join(os.homedir(), '.bash_profile'),
|
|
13
|
+
];
|
|
14
|
+
|
|
8
15
|
export function findPackageRoot(startUrl = import.meta.url) {
|
|
9
16
|
let current = path.dirname(fileURLToPath(startUrl));
|
|
10
17
|
while (true) {
|
|
@@ -99,3 +106,45 @@ export function ensureHomeBinLink(runtime) {
|
|
|
99
106
|
}
|
|
100
107
|
fs.symlinkSync(runtime.localBinPath, runtime.homeBinLink);
|
|
101
108
|
}
|
|
109
|
+
|
|
110
|
+
export function ensurePathInShellConfigs(binDir) {
|
|
111
|
+
const exportLine = `export PATH="${binDir}:$PATH"`;
|
|
112
|
+
for (const configFile of SHELL_CONFIG_FILES) {
|
|
113
|
+
try {
|
|
114
|
+
if (!fs.existsSync(configFile)) continue;
|
|
115
|
+
const content = fs.readFileSync(configFile, 'utf8');
|
|
116
|
+
if (content.includes(SHELL_CONFIG_MARKER)) continue;
|
|
117
|
+
const prefix = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
|
|
118
|
+
fs.appendFileSync(configFile, `${prefix}\n${SHELL_CONFIG_MARKER}\n${exportLine}\n`);
|
|
119
|
+
} catch {
|
|
120
|
+
// best-effort, ignore errors
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function removePathFromShellConfigs() {
|
|
126
|
+
for (const configFile of SHELL_CONFIG_FILES) {
|
|
127
|
+
try {
|
|
128
|
+
if (!fs.existsSync(configFile)) continue;
|
|
129
|
+
const content = fs.readFileSync(configFile, 'utf8');
|
|
130
|
+
if (!content.includes(SHELL_CONFIG_MARKER)) continue;
|
|
131
|
+
const lines = content.split('\n');
|
|
132
|
+
const filtered = [];
|
|
133
|
+
for (let i = 0; i < lines.length; i++) {
|
|
134
|
+
if (lines[i] === SHELL_CONFIG_MARKER) {
|
|
135
|
+
if (i + 1 < lines.length && lines[i + 1].startsWith('export PATH=')) {
|
|
136
|
+
i += 1;
|
|
137
|
+
}
|
|
138
|
+
if (filtered.length > 0 && filtered[filtered.length - 1] === '') {
|
|
139
|
+
filtered.pop();
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
filtered.push(lines[i]);
|
|
144
|
+
}
|
|
145
|
+
fs.writeFileSync(configFile, filtered.join('\n'));
|
|
146
|
+
} catch {
|
|
147
|
+
// best-effort, ignore errors
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/launchd.js
CHANGED
|
@@ -96,21 +96,37 @@ export class LaunchdServiceManager {
|
|
|
96
96
|
if (!this.runtime.entryFile || !fs.existsSync(this.runtime.entryFile)) {
|
|
97
97
|
throw new Error(`installed runtime entry not found: ${this.runtime.entryFile || '(empty)'}`);
|
|
98
98
|
}
|
|
99
|
+
const plist = buildLaunchdPlist(this.runtime);
|
|
99
100
|
fs.mkdirSync(path.dirname(this.runtime.plistPath), { recursive: true });
|
|
100
101
|
fs.mkdirSync(path.dirname(this.runtime.stdoutLogPath), { recursive: true });
|
|
101
|
-
fs.writeFileSync(this.runtime.plistPath,
|
|
102
|
+
fs.writeFileSync(this.runtime.plistPath, plist);
|
|
103
|
+
this.syncLaunchAgent(plist);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
syncLaunchAgent(plist) {
|
|
107
|
+
const launchAgentPath = this.runtime.launchAgentPath;
|
|
108
|
+
if (!launchAgentPath) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (this.runtime.autoStartOnLogin) {
|
|
112
|
+
fs.mkdirSync(path.dirname(launchAgentPath), { recursive: true });
|
|
113
|
+
fs.writeFileSync(launchAgentPath, plist);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (fs.existsSync(launchAgentPath)) {
|
|
117
|
+
fs.rmSync(launchAgentPath, { force: true });
|
|
118
|
+
}
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
start() {
|
|
105
122
|
this.ensurePlist();
|
|
106
123
|
this.stop();
|
|
107
124
|
this.run(['launchctl', 'bootstrap', this.domainTarget(), this.runtime.plistPath]);
|
|
108
|
-
this.run(['launchctl', 'kickstart', '-k', this.serviceTarget()], { check: false });
|
|
109
125
|
}
|
|
110
126
|
|
|
111
127
|
stop() {
|
|
112
128
|
this.ensureMacos();
|
|
113
|
-
this.run(['launchctl', 'bootout', this.
|
|
129
|
+
this.run(['launchctl', 'bootout', this.serviceTarget()], { check: false });
|
|
114
130
|
}
|
|
115
131
|
|
|
116
132
|
restart() {
|
|
@@ -137,6 +153,9 @@ export class LaunchdServiceManager {
|
|
|
137
153
|
if (fs.existsSync(this.runtime.plistPath)) {
|
|
138
154
|
fs.unlinkSync(this.runtime.plistPath);
|
|
139
155
|
}
|
|
156
|
+
if (this.runtime.launchAgentPath && fs.existsSync(this.runtime.launchAgentPath)) {
|
|
157
|
+
fs.unlinkSync(this.runtime.launchAgentPath);
|
|
158
|
+
}
|
|
140
159
|
if (fs.existsSync(this.runtime.homeBinLink) && fs.lstatSync(this.runtime.homeBinLink).isSymbolicLink()) {
|
|
141
160
|
try {
|
|
142
161
|
const target = fs.realpathSync.native(this.runtime.homeBinLink);
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import {
|
|
5
|
+
ARCHIVED_SESSIONS_SOURCE_ROOT,
|
|
6
|
+
SESSIONS_SOURCE_ROOT,
|
|
7
|
+
} from './constants.js';
|
|
8
|
+
import { RolloutParser } from './parser.js';
|
|
9
|
+
|
|
10
|
+
const LOCAL_USAGE_COLLECTOR = Object.freeze({ collectorId: 'local-usage' });
|
|
11
|
+
const VALID_PERIODS = new Set(['today', '7d', '30d', 'all']);
|
|
12
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
13
|
+
|
|
14
|
+
export function getLocalTimeZone() {
|
|
15
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveUsageQuery({
|
|
19
|
+
period,
|
|
20
|
+
from,
|
|
21
|
+
to,
|
|
22
|
+
now = Date.now(),
|
|
23
|
+
timeZone = getLocalTimeZone(),
|
|
24
|
+
} = {}) {
|
|
25
|
+
if (period && (from || to)) {
|
|
26
|
+
throw new Error('--period cannot be combined with --from or --to');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (period && !VALID_PERIODS.has(period)) {
|
|
30
|
+
throw new Error(`--period must be one of: today, 7d, 30d, all`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const today = formatDateInTimeZone(now, timeZone);
|
|
34
|
+
|
|
35
|
+
if (period === 'today') {
|
|
36
|
+
return {
|
|
37
|
+
mode: 'today',
|
|
38
|
+
from: today,
|
|
39
|
+
to: today,
|
|
40
|
+
trendFrom: today,
|
|
41
|
+
trendTo: today,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (period === '7d') {
|
|
46
|
+
return {
|
|
47
|
+
mode: '7d',
|
|
48
|
+
from: addDays(today, -6),
|
|
49
|
+
to: today,
|
|
50
|
+
trendFrom: addDays(today, -6),
|
|
51
|
+
trendTo: today,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (period === '30d') {
|
|
56
|
+
return {
|
|
57
|
+
mode: '30d',
|
|
58
|
+
from: addDays(today, -29),
|
|
59
|
+
to: today,
|
|
60
|
+
trendFrom: addDays(today, -29),
|
|
61
|
+
trendTo: today,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (period === 'all' || (!period && !from && !to)) {
|
|
66
|
+
return {
|
|
67
|
+
mode: 'all',
|
|
68
|
+
from: null,
|
|
69
|
+
to: null,
|
|
70
|
+
trendFrom: addDays(today, -29),
|
|
71
|
+
trendTo: today,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const normalizedFrom = from == null ? null : normalizeDateString(from, '--from');
|
|
76
|
+
const normalizedTo = to == null ? null : normalizeDateString(to, '--to');
|
|
77
|
+
|
|
78
|
+
if (normalizedFrom && normalizedTo && normalizedFrom > normalizedTo) {
|
|
79
|
+
throw new Error('--from cannot be later than --to');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
mode: 'custom',
|
|
84
|
+
from: normalizedFrom,
|
|
85
|
+
to: normalizedTo,
|
|
86
|
+
trendFrom: normalizedFrom,
|
|
87
|
+
trendTo: normalizedTo,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function collectLocalUsage({
|
|
92
|
+
sessionsDir,
|
|
93
|
+
period,
|
|
94
|
+
from,
|
|
95
|
+
to,
|
|
96
|
+
now = Date.now(),
|
|
97
|
+
timeZone = getLocalTimeZone(),
|
|
98
|
+
includeArchived = true,
|
|
99
|
+
} = {}) {
|
|
100
|
+
const range = resolveUsageQuery({ period, from, to, now, timeZone });
|
|
101
|
+
const archivedSessionsDir = path.join(
|
|
102
|
+
path.dirname(sessionsDir),
|
|
103
|
+
ARCHIVED_SESSIONS_SOURCE_ROOT,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const roots = [
|
|
107
|
+
{ sourceRoot: SESSIONS_SOURCE_ROOT, dir: sessionsDir },
|
|
108
|
+
];
|
|
109
|
+
if (includeArchived) {
|
|
110
|
+
roots.push({
|
|
111
|
+
sourceRoot: ARCHIVED_SESSIONS_SOURCE_ROOT,
|
|
112
|
+
dir: archivedSessionsDir,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const entries = roots.flatMap((root) =>
|
|
117
|
+
iterRolloutFiles(root.dir).map((filePath) => ({
|
|
118
|
+
...root,
|
|
119
|
+
filePath,
|
|
120
|
+
relpath: path.relative(root.dir, filePath).split(path.sep).join('/'),
|
|
121
|
+
})),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
entries.sort((left, right) => left.filePath.localeCompare(right.filePath));
|
|
125
|
+
|
|
126
|
+
const summary = emptyUsageRow();
|
|
127
|
+
const dayMap = new Map();
|
|
128
|
+
const trendMap = new Map();
|
|
129
|
+
const modelMap = new Map();
|
|
130
|
+
let tokenEventCount = 0;
|
|
131
|
+
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
const parser = new RolloutParser(
|
|
134
|
+
LOCAL_USAGE_COLLECTOR,
|
|
135
|
+
`${entry.sourceRoot}/${entry.relpath}`,
|
|
136
|
+
);
|
|
137
|
+
const input = fs.createReadStream(entry.filePath, { encoding: 'utf8' });
|
|
138
|
+
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
139
|
+
let lineNo = 0;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
for await (const line of rl) {
|
|
143
|
+
lineNo += 1;
|
|
144
|
+
if (!line) continue;
|
|
145
|
+
const parsed = parser.processLine(lineNo, line);
|
|
146
|
+
for (const event of parsed.events) {
|
|
147
|
+
const eventDate = formatDateInTimeZone(event.timestamp, timeZone);
|
|
148
|
+
if (!isWithinDateRange(eventDate, range.from, range.to)) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const usage = usageFromEvent(event);
|
|
153
|
+
tokenEventCount += 1;
|
|
154
|
+
accumulateUsage(summary, usage);
|
|
155
|
+
|
|
156
|
+
const currentDay = dayMap.get(eventDate) ?? {
|
|
157
|
+
date: eventDate,
|
|
158
|
+
eventCount: 0,
|
|
159
|
+
models: new Set(),
|
|
160
|
+
...emptyUsageRow(),
|
|
161
|
+
};
|
|
162
|
+
currentDay.eventCount += 1;
|
|
163
|
+
currentDay.models.add(usage.model);
|
|
164
|
+
accumulateUsage(currentDay, usage);
|
|
165
|
+
dayMap.set(eventDate, currentDay);
|
|
166
|
+
|
|
167
|
+
const currentModel = modelMap.get(usage.model) ?? {
|
|
168
|
+
model: usage.model,
|
|
169
|
+
eventCount: 0,
|
|
170
|
+
...emptyUsageRow(),
|
|
171
|
+
};
|
|
172
|
+
currentModel.eventCount += 1;
|
|
173
|
+
accumulateUsage(currentModel, usage);
|
|
174
|
+
modelMap.set(usage.model, currentModel);
|
|
175
|
+
|
|
176
|
+
if (isWithinDateRange(eventDate, range.trendFrom, range.trendTo)) {
|
|
177
|
+
const currentDate = trendMap.get(eventDate) ?? {
|
|
178
|
+
date: eventDate,
|
|
179
|
+
eventCount: 0,
|
|
180
|
+
...emptyUsageRow(),
|
|
181
|
+
};
|
|
182
|
+
currentDate.eventCount += 1;
|
|
183
|
+
accumulateUsage(currentDate, usage);
|
|
184
|
+
trendMap.set(eventDate, currentDate);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
rl.close();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const days = [...dayMap.values()]
|
|
194
|
+
.sort((left, right) => left.date.localeCompare(right.date))
|
|
195
|
+
.map((row) => ({
|
|
196
|
+
...row,
|
|
197
|
+
models: [...row.models].sort((left, right) => left.localeCompare(right)),
|
|
198
|
+
}));
|
|
199
|
+
const trend = materializeTrendRows(range, trendMap);
|
|
200
|
+
const byModel = [...modelMap.values()].sort((left, right) => {
|
|
201
|
+
if (right.totalTokens !== left.totalTokens) {
|
|
202
|
+
return right.totalTokens - left.totalTokens;
|
|
203
|
+
}
|
|
204
|
+
return left.model.localeCompare(right.model);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
scope: 'local-machine',
|
|
209
|
+
timezone: timeZone,
|
|
210
|
+
range,
|
|
211
|
+
sources: {
|
|
212
|
+
sessionsDir,
|
|
213
|
+
archivedSessionsDir,
|
|
214
|
+
includeArchived,
|
|
215
|
+
},
|
|
216
|
+
filesScanned: entries.length,
|
|
217
|
+
tokenEventCount,
|
|
218
|
+
summary,
|
|
219
|
+
days,
|
|
220
|
+
trend,
|
|
221
|
+
byModel,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function iterRolloutFiles(rootDir) {
|
|
226
|
+
if (!fs.existsSync(rootDir)) return [];
|
|
227
|
+
const files = [];
|
|
228
|
+
const walk = (dir) => {
|
|
229
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
230
|
+
const fullPath = path.join(dir, entry.name);
|
|
231
|
+
if (entry.isDirectory()) {
|
|
232
|
+
walk(fullPath);
|
|
233
|
+
} else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name)) {
|
|
234
|
+
files.push(fullPath);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
walk(rootDir);
|
|
239
|
+
return files;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function emptyUsageRow() {
|
|
243
|
+
return {
|
|
244
|
+
inputTokens: 0,
|
|
245
|
+
cachedInputTokens: 0,
|
|
246
|
+
outputTokens: 0,
|
|
247
|
+
reasoningOutputTokens: 0,
|
|
248
|
+
totalTokens: 0,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function materializeTrendRows(range, trendMap) {
|
|
253
|
+
const rows = [...trendMap.values()].sort((left, right) =>
|
|
254
|
+
left.date.localeCompare(right.date),
|
|
255
|
+
);
|
|
256
|
+
if (rows.length === 0) {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
if (!range.trendFrom || !range.trendTo || range.trendFrom > range.trendTo) {
|
|
260
|
+
return rows;
|
|
261
|
+
}
|
|
262
|
+
const materialized = [];
|
|
263
|
+
for (let date = range.trendFrom; date <= range.trendTo; date = addDays(date, 1)) {
|
|
264
|
+
materialized.push(
|
|
265
|
+
trendMap.get(date) ?? {
|
|
266
|
+
date,
|
|
267
|
+
eventCount: 0,
|
|
268
|
+
...emptyUsageRow(),
|
|
269
|
+
},
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return materialized;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function usageFromEvent(event) {
|
|
276
|
+
return {
|
|
277
|
+
model: event.model ?? '(unknown)',
|
|
278
|
+
inputTokens: numberOrZero(event.lastInputTokens),
|
|
279
|
+
cachedInputTokens: numberOrZero(event.lastCachedInputTokens),
|
|
280
|
+
outputTokens: numberOrZero(event.lastOutputTokens),
|
|
281
|
+
reasoningOutputTokens: numberOrZero(event.lastReasoningOutputTokens),
|
|
282
|
+
totalTokens: numberOrZero(event.lastTotalTokens),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function accumulateUsage(target, usage) {
|
|
287
|
+
target.inputTokens += usage.inputTokens;
|
|
288
|
+
target.cachedInputTokens += usage.cachedInputTokens;
|
|
289
|
+
target.outputTokens += usage.outputTokens;
|
|
290
|
+
target.reasoningOutputTokens += usage.reasoningOutputTokens;
|
|
291
|
+
target.totalTokens += usage.totalTokens;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function numberOrZero(value) {
|
|
295
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function formatDateInTimeZone(timestampMs, timeZone) {
|
|
299
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
300
|
+
timeZone,
|
|
301
|
+
year: 'numeric',
|
|
302
|
+
month: '2-digit',
|
|
303
|
+
day: '2-digit',
|
|
304
|
+
}).formatToParts(new Date(timestampMs));
|
|
305
|
+
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
|
306
|
+
return `${values.year}-${values.month}-${values.day}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function normalizeDateString(value, label) {
|
|
310
|
+
if (!DATE_RE.test(String(value))) {
|
|
311
|
+
throw new Error(`${label} must be in YYYY-MM-DD format`);
|
|
312
|
+
}
|
|
313
|
+
const [yearRaw, monthRaw, dayRaw] = String(value).split('-');
|
|
314
|
+
const year = Number(yearRaw);
|
|
315
|
+
const month = Number(monthRaw);
|
|
316
|
+
const day = Number(dayRaw);
|
|
317
|
+
const normalized = new Date(Date.UTC(year, month - 1, day));
|
|
318
|
+
if (
|
|
319
|
+
Number.isNaN(normalized.getTime()) ||
|
|
320
|
+
normalized.getUTCFullYear() !== year ||
|
|
321
|
+
normalized.getUTCMonth() !== month - 1 ||
|
|
322
|
+
normalized.getUTCDate() !== day
|
|
323
|
+
) {
|
|
324
|
+
throw new Error(`${label} must be a valid calendar date`);
|
|
325
|
+
}
|
|
326
|
+
return `${yearRaw}-${monthRaw}-${dayRaw}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function addDays(dateString, deltaDays) {
|
|
330
|
+
const [year, month, day] = dateString.split('-').map(Number);
|
|
331
|
+
const shifted = new Date(Date.UTC(year, month - 1, day + deltaDays));
|
|
332
|
+
const nextYear = shifted.getUTCFullYear();
|
|
333
|
+
const nextMonth = String(shifted.getUTCMonth() + 1).padStart(2, '0');
|
|
334
|
+
const nextDay = String(shifted.getUTCDate()).padStart(2, '0');
|
|
335
|
+
return `${nextYear}-${nextMonth}-${nextDay}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function isWithinDateRange(date, from, to) {
|
|
339
|
+
if (from && date < from) return false;
|
|
340
|
+
if (to && date > to) return false;
|
|
341
|
+
return true;
|
|
342
|
+
}
|
package/src/runtime-config.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
DEFAULT_CURRENT_APP_DIR,
|
|
10
10
|
DEFAULT_HOME_BIN_LINK,
|
|
11
11
|
DEFAULT_INSTALL_ROOT,
|
|
12
|
+
DEFAULT_LAUNCH_AGENT_PATH,
|
|
12
13
|
DEFAULT_LAUNCHD_LABEL,
|
|
13
14
|
DEFAULT_LOCAL_BIN_DIR,
|
|
14
15
|
DEFAULT_LOCAL_BIN_PATH,
|
|
@@ -26,6 +27,7 @@ export function defaultRuntimeConfig(configFile = DEFAULT_CONFIG_FILE) {
|
|
|
26
27
|
const installRoot = path.dirname(configFile);
|
|
27
28
|
const appRoot = path.join(installRoot, 'app');
|
|
28
29
|
const currentAppDir = path.join(appRoot, 'current');
|
|
30
|
+
const launchdLabel = DEFAULT_LAUNCHD_LABEL;
|
|
29
31
|
return {
|
|
30
32
|
configFile,
|
|
31
33
|
installRoot,
|
|
@@ -44,8 +46,13 @@ export function defaultRuntimeConfig(configFile = DEFAULT_CONFIG_FILE) {
|
|
|
44
46
|
homeBinLink: DEFAULT_HOME_BIN_LINK,
|
|
45
47
|
stdoutLogPath: path.join(installRoot, 'logs', path.basename(DEFAULT_STDOUT_LOG_PATH)),
|
|
46
48
|
stderrLogPath: path.join(installRoot, 'logs', path.basename(DEFAULT_STDERR_LOG_PATH)),
|
|
47
|
-
launchdLabel
|
|
48
|
-
plistPath:
|
|
49
|
+
launchdLabel,
|
|
50
|
+
plistPath: path.join(installRoot, 'launchd', `${launchdLabel}.plist`),
|
|
51
|
+
launchAgentPath: path.join(
|
|
52
|
+
path.dirname(DEFAULT_LAUNCH_AGENT_PATH),
|
|
53
|
+
`${launchdLabel}.plist`,
|
|
54
|
+
),
|
|
55
|
+
autoStartOnLogin: true,
|
|
49
56
|
};
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -65,6 +72,17 @@ export function normalizeRuntimeConfig(runtime) {
|
|
|
65
72
|
const appRoot = runtime.appRoot || path.join(installRoot, 'app');
|
|
66
73
|
const currentAppDir = runtime.currentAppDir || path.join(appRoot, 'current');
|
|
67
74
|
const localBinDir = runtime.localBinDir || DEFAULT_LOCAL_BIN_DIR;
|
|
75
|
+
const launchdLabel = runtime.launchdLabel || DEFAULT_LAUNCHD_LABEL;
|
|
76
|
+
const defaultPlistPath = path.join(installRoot, 'launchd', `${launchdLabel}.plist`);
|
|
77
|
+
const defaultLaunchAgentPath = path.join(
|
|
78
|
+
path.dirname(DEFAULT_LAUNCH_AGENT_PATH),
|
|
79
|
+
`${launchdLabel}.plist`,
|
|
80
|
+
);
|
|
81
|
+
const legacyLaunchAgentPath = path.join(
|
|
82
|
+
path.dirname(DEFAULT_LAUNCH_AGENT_PATH),
|
|
83
|
+
`${launchdLabel}.plist`,
|
|
84
|
+
);
|
|
85
|
+
const rawPlistPath = runtime.plistPath || defaultPlistPath;
|
|
68
86
|
return {
|
|
69
87
|
...runtime,
|
|
70
88
|
configFile: runtime.configFile || DEFAULT_CONFIG_FILE,
|
|
@@ -84,8 +102,14 @@ export function normalizeRuntimeConfig(runtime) {
|
|
|
84
102
|
homeBinLink: runtime.homeBinLink || DEFAULT_HOME_BIN_LINK,
|
|
85
103
|
stdoutLogPath: runtime.stdoutLogPath || path.join(installRoot, 'logs', 'stdout.log'),
|
|
86
104
|
stderrLogPath: runtime.stderrLogPath || path.join(installRoot, 'logs', 'stderr.log'),
|
|
87
|
-
launchdLabel
|
|
88
|
-
plistPath:
|
|
105
|
+
launchdLabel,
|
|
106
|
+
plistPath:
|
|
107
|
+
rawPlistPath === legacyLaunchAgentPath
|
|
108
|
+
? defaultPlistPath
|
|
109
|
+
: rawPlistPath,
|
|
110
|
+
launchAgentPath: runtime.launchAgentPath || defaultLaunchAgentPath,
|
|
111
|
+
autoStartOnLogin:
|
|
112
|
+
runtime.autoStartOnLogin === undefined ? true : Boolean(runtime.autoStartOnLogin),
|
|
89
113
|
};
|
|
90
114
|
}
|
|
91
115
|
|
|
@@ -112,6 +136,8 @@ export function saveRuntimeConfig(runtime) {
|
|
|
112
136
|
stderrLogPath: normalized.stderrLogPath,
|
|
113
137
|
launchdLabel: normalized.launchdLabel,
|
|
114
138
|
plistPath: normalized.plistPath,
|
|
139
|
+
launchAgentPath: normalized.launchAgentPath,
|
|
140
|
+
autoStartOnLogin: normalized.autoStartOnLogin,
|
|
115
141
|
})}\n`);
|
|
116
142
|
return normalized;
|
|
117
143
|
}
|
|
@@ -136,7 +162,9 @@ export function formatStatusOutput(runtime, status) {
|
|
|
136
162
|
stdoutLogPath: runtime.stdoutLogPath,
|
|
137
163
|
stderrLogPath: runtime.stderrLogPath,
|
|
138
164
|
plistPath: runtime.plistPath,
|
|
165
|
+
launchAgentPath: runtime.launchAgentPath,
|
|
139
166
|
label: runtime.launchdLabel,
|
|
167
|
+
autoStartOnLogin: runtime.autoStartOnLogin,
|
|
140
168
|
onlineThresholdSeconds: STATUS_ONLINE_THRESHOLD_SECONDS,
|
|
141
169
|
};
|
|
142
170
|
}
|
package/src/utils.js
CHANGED
|
@@ -51,3 +51,18 @@ export function computeEventUid(collectorId, relpath, lineNo) {
|
|
|
51
51
|
const raw = Buffer.from(`${collectorId}|${relpath}|${lineNo}`, 'utf8');
|
|
52
52
|
return createHash('sha1').update(raw).digest('hex');
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
export function readPersistentCollectorId(filePath) {
|
|
56
|
+
try {
|
|
57
|
+
const content = fs.readFileSync(filePath, 'utf8').trim();
|
|
58
|
+
return content || null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function writePersistentCollectorId(filePath, id) {
|
|
65
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
66
|
+
fs.writeFileSync(tmpPath, id, 'utf8');
|
|
67
|
+
fs.renameSync(tmpPath, filePath);
|
|
68
|
+
}
|