@brierb/brier-cli 0.0.7 → 0.0.8

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 (63) hide show
  1. package/dist/commands/log.js +28 -0
  2. package/dist/commands/restart.js +9 -4
  3. package/dist/commands/run.js +13 -0
  4. package/dist/commands/start.js +2 -2
  5. package/dist/commands/status.js +11 -16
  6. package/dist/commands/stop.js +1 -1
  7. package/dist/config/credentials.js +25 -0
  8. package/dist/config/index.js +16 -0
  9. package/dist/config/load.js +26 -0
  10. package/dist/config/paths.js +14 -0
  11. package/dist/config/version.js +13 -0
  12. package/dist/core/index.js +11 -0
  13. package/dist/{runtimes.js → core/runtimes.js} +6 -0
  14. package/dist/daemon/DaemonManager.js +65 -66
  15. package/dist/daemon/DaemonRunner.js +97 -39
  16. package/dist/daemon/OutputBatcher.js +77 -0
  17. package/dist/daemon/TaskExecutor.js +72 -8
  18. package/dist/daemon/index.js +13 -0
  19. package/dist/daemon/state.js +100 -0
  20. package/dist/definitions/daemon.js +5 -0
  21. package/dist/definitions/index.js +12 -0
  22. package/dist/definitions/tunnel.js +9 -0
  23. package/dist/index.js +12 -31
  24. package/dist/tunnel/backoff.js +21 -0
  25. package/dist/tunnel/dispatcher.js +35 -0
  26. package/dist/tunnel/heartbeat.js +55 -0
  27. package/dist/tunnel/index.js +220 -0
  28. package/dist/tunnel/transport.js +112 -0
  29. package/dist/tunnel/url.js +10 -0
  30. package/package.json +3 -3
  31. package/types/commands/log.d.ts +5 -0
  32. package/types/commands/run.d.ts +5 -0
  33. package/types/commands/status.d.ts +1 -0
  34. package/types/config/credentials.d.ts +8 -0
  35. package/types/config/index.d.ts +16 -0
  36. package/types/config/load.d.ts +7 -0
  37. package/types/config/paths.d.ts +12 -0
  38. package/types/config/version.d.ts +2 -0
  39. package/types/core/index.d.ts +11 -0
  40. package/types/{runtimes.d.ts → core/runtimes.d.ts} +6 -0
  41. package/types/daemon/DaemonManager.d.ts +1 -1
  42. package/types/daemon/OutputBatcher.d.ts +39 -0
  43. package/types/daemon/TaskExecutor.d.ts +4 -2
  44. package/types/daemon/index.d.ts +13 -0
  45. package/types/daemon/state.d.ts +19 -0
  46. package/types/definitions/daemon.d.ts +58 -0
  47. package/types/definitions/index.d.ts +17 -0
  48. package/types/definitions/task.d.ts +25 -0
  49. package/types/definitions/tunnel.d.ts +112 -0
  50. package/types/tunnel/backoff.d.ts +32 -0
  51. package/types/tunnel/dispatcher.d.ts +26 -0
  52. package/types/tunnel/heartbeat.d.ts +36 -0
  53. package/types/tunnel/index.d.ts +38 -0
  54. package/types/tunnel/transport.d.ts +29 -0
  55. package/types/tunnel/url.d.ts +5 -0
  56. package/dist/config.js +0 -45
  57. package/dist/tunnel/TunnelClient.js +0 -218
  58. package/types/config.d.ts +0 -9
  59. package/types/tunnel/TunnelClient.d.ts +0 -10
  60. package/types/types.d.ts +0 -78
  61. /package/dist/{logger.js → core/logger.js} +0 -0
  62. /package/dist/{types.js → definitions/task.js} +0 -0
  63. /package/types/{logger.d.ts → core/logger.d.ts} +0 -0
@@ -0,0 +1,28 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { LOG_FILE } from '../config/index.js';
3
+ /** 默认打印的最近日志行数 */
4
+ const TAIL_LINES = 100;
5
+ /**
6
+ * `brier daemon log`:查看后台服务日志。
7
+ * 输出 ~/.brier/daemon.log 最近 100 行;文件不存在时给出提示与路径。
8
+ */
9
+ export const logCommand = () => {
10
+ if (!existsSync(LOG_FILE)) {
11
+ console.log('尚未生成日志,daemon 可能从未启动过');
12
+ console.log(` 日志路径: ${LOG_FILE}`);
13
+ return;
14
+ }
15
+ const content = readFileSync(LOG_FILE, 'utf-8');
16
+ const allLines = content.split('\n');
17
+ // 去掉文件末尾换行产生的空行
18
+ if (allLines.length > 0 && allLines[allLines.length - 1] === '') {
19
+ allLines.pop();
20
+ }
21
+ const tail = allLines.slice(-TAIL_LINES);
22
+ if (tail.length === 0) {
23
+ console.log(`日志为空(${LOG_FILE})`);
24
+ return;
25
+ }
26
+ console.log(`最近 ${tail.length} 行日志(${LOG_FILE}):`);
27
+ console.log(tail.join('\n'));
28
+ };
@@ -1,11 +1,16 @@
1
- import { createDaemonManager } from '../daemon/DaemonManager.js';
2
- import { loadConfig } from '../config.js';
1
+ import { createDaemonManager, readDaemonState } from '../daemon/index.js';
2
+ import { loadConfig, readStoredToken } from '../config/index.js';
3
3
  export const restartCommand = async (options) => {
4
4
  let serverUrl = options.serverUrl;
5
- let token = options.token ?? process.env.BRIER_TOKEN;
5
+ // token 优先级:--token BRIER_TOKEN 环境变量 → 持久化的接入令牌(0600 的 credentials.json)
6
+ let token = options.token ?? process.env.BRIER_TOKEN ?? readStoredToken();
7
+ // 未显式指定 server-url 时,优先沿用当前 daemon 记录的地址(状态文件),再退回环境变量
8
+ if (!serverUrl) {
9
+ serverUrl = readDaemonState()?.serverUrl;
10
+ }
6
11
  if (!serverUrl || !token) {
7
12
  try {
8
- const config = loadConfig({});
13
+ const config = loadConfig();
9
14
  if (!serverUrl)
10
15
  serverUrl = config.serverUrl;
11
16
  if (!token)
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 命令执行包装:统一 try/catch 与失败退出码。
3
+ * commander 的 action 直接返回 runCommand(...),消除各命令重复的错误处理样板。
4
+ */
5
+ export const runCommand = async (fn) => {
6
+ try {
7
+ await fn();
8
+ }
9
+ catch (err) {
10
+ console.error('Error:', err instanceof Error ? err.message : String(err));
11
+ process.exit(1);
12
+ }
13
+ };
@@ -1,5 +1,5 @@
1
- import { createDaemonManager } from '../daemon/DaemonManager.js';
2
- import { LOG_FILE } from '../config.js';
1
+ import { createDaemonManager } from '../daemon/index.js';
2
+ import { LOG_FILE } from '../config/index.js';
3
3
  export const startCommand = async (options) => {
4
4
  const token = options.token ?? process.env.BRIER_TOKEN;
5
5
  if (!token) {
@@ -1,24 +1,19 @@
1
- import { createDaemonManager } from '../daemon/DaemonManager.js';
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import { PID_FILE } from '../config.js';
1
+ import { createDaemonManager, readDaemonState } from '../daemon/index.js';
2
+ /** `brier daemon status`:展示 daemon 运行状态与状态文件详情。 */
4
3
  export const statusCommand = () => {
5
4
  const manager = createDaemonManager();
6
5
  const status = manager.status();
7
6
  if (status === 'running') {
8
- let pidInfo = null;
9
- if (existsSync(PID_FILE)) {
10
- try {
11
- pidInfo = JSON.parse(readFileSync(PID_FILE, 'utf-8'));
12
- }
13
- catch {
14
- // ignore
15
- }
16
- }
7
+ const state = readDaemonState();
17
8
  console.log('● 后台服务运行中');
18
- if (pidInfo) {
19
- console.log(` PID: ${pidInfo.pid}`);
20
- console.log(` Server: ${pidInfo.serverUrl}`);
21
- console.log(` Started: ${new Date(pidInfo.startTime).toLocaleString()}`);
9
+ if (state) {
10
+ console.log(` PID: ${state.pid}`);
11
+ console.log(` Server: ${state.serverUrl}`);
12
+ console.log(` Started: ${new Date(state.startTime).toLocaleString()}`);
13
+ console.log(` Ready: ${state.ready ? '是' : '否(启动中)'}`);
14
+ if (state.tunnelState) {
15
+ console.log(` 隧道状态: ${state.tunnelState}`);
16
+ }
22
17
  }
23
18
  }
24
19
  else {
@@ -1,4 +1,4 @@
1
- import { createDaemonManager } from '../daemon/DaemonManager.js';
1
+ import { createDaemonManager } from '../daemon/index.js';
2
2
  export const stopCommand = async () => {
3
3
  const manager = createDaemonManager();
4
4
  await manager.stop();
@@ -0,0 +1,25 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { CREDENTIALS_FILE } from './paths.js';
4
+ /** 读取持久化的接入令牌;文件缺失或损坏时返回 undefined。 */
5
+ export const readStoredToken = () => {
6
+ try {
7
+ const data = JSON.parse(readFileSync(CREDENTIALS_FILE, 'utf-8'));
8
+ return typeof data.token === 'string' && data.token.length > 0 ? data.token : undefined;
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ };
14
+ /**
15
+ * 持久化接入令牌到 ~/.brier/credentials.json。
16
+ * 写入使用 mode 0600,并对已存在的文件强制收紧权限(防止先前被宽权限创建)。
17
+ * 失败会抛错,由调用方决定是否阻断启动。
18
+ */
19
+ export const writeCredentials = (token) => {
20
+ mkdirSync(dirname(CREDENTIALS_FILE), { recursive: true });
21
+ writeFileSync(CREDENTIALS_FILE, JSON.stringify({ token }, null, 2), {
22
+ mode: 0o600,
23
+ });
24
+ chmodSync(CREDENTIALS_FILE, 0o600);
25
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * 配置域统一出口:提供 daemon 装配所需的全部环境信息。
3
+ *
4
+ * 目录内文件职责:
5
+ * - load.ts: loadConfig(),从环境变量装配 DaemonConfig
6
+ * - paths.ts: 应用路径常量(~/.brier 下的 PID/日志文件)
7
+ * - version.ts:CLI 版本读取(package.json)
8
+ */
9
+ /** daemon 装配配置(BRIER_SERVER_URL / BRIER_TOKEN → DaemonConfig) */
10
+ export { loadConfig } from './load.js';
11
+ /** 应用路径常量:BRIER_DIR / DAEMON_STATE_FILE / LOG_FILE / CREDENTIALS_FILE */
12
+ export { BRIER_DIR, DAEMON_STATE_FILE, LOG_FILE, CREDENTIALS_FILE } from './paths.js';
13
+ /** CLI 版本读取 */
14
+ export { readCliVersion } from './version.js';
15
+ /** 接入令牌持久化(credentials.json,0600) */
16
+ export { readStoredToken, writeCredentials } from './credentials.js';
@@ -0,0 +1,26 @@
1
+ import { arch, hostname as getHostname, platform, type as osType } from 'node:os';
2
+ import { detectInstalledRuntimes } from '../core/index.js';
3
+ import { readCliVersion } from './version.js';
4
+ /**
5
+ * daemon 装配配置加载:从 BRIER_SERVER_URL / BRIER_TOKEN 环境变量
6
+ * 读取 serverUrl / token,并组合本机信息(hostname/os/runtimes/version)。
7
+ * 校验失败(缺少必填项)时抛出带说明的错误。
8
+ */
9
+ export const loadConfig = () => {
10
+ const serverUrl = process.env.BRIER_SERVER_URL;
11
+ const token = process.env.BRIER_TOKEN;
12
+ if (!serverUrl) {
13
+ throw new Error('Server URL is required. Use --server-url or set BRIER_SERVER_URL');
14
+ }
15
+ if (!token) {
16
+ throw new Error('BRIER_TOKEN is required. Pass --token or set BRIER_TOKEN env var');
17
+ }
18
+ return {
19
+ serverUrl,
20
+ token,
21
+ hostname: getHostname(),
22
+ os: `${osType()} ${platform()} ${arch()}`,
23
+ runtimes: detectInstalledRuntimes(),
24
+ version: readCliVersion(),
25
+ };
26
+ };
@@ -0,0 +1,14 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * 应用文件路径常量。
5
+ * 集中到 ~/.brier/ 下,保证与 daemon 启动时的工作目录无关。
6
+ */
7
+ /** Brier 用户数据目录(~/.brier) */
8
+ export const BRIER_DIR = join(homedir(), '.brier');
9
+ /** daemon 运行状态文件(JSON,结构见 definitions/daemon.ts 的 DaemonState) */
10
+ export const DAEMON_STATE_FILE = join(BRIER_DIR, 'daemon.json');
11
+ /** daemon 运行日志文件 */
12
+ export const LOG_FILE = join(BRIER_DIR, 'daemon.log');
13
+ /** daemon 接入令牌持久化文件(0600,供 `daemon restart` 无参回退) */
14
+ export const CREDENTIALS_FILE = join(BRIER_DIR, 'credentials.json');
@@ -0,0 +1,13 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ /** CLI 自身版本:读取 dist 两级的 package.json(与 `brier --version` 同源)。 */
4
+ export const readCliVersion = () => {
5
+ try {
6
+ const pkgPath = fileURLToPath(new URL('../../package.json', import.meta.url));
7
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
8
+ return pkg.version ?? 'unknown';
9
+ }
10
+ catch {
11
+ return 'unknown';
12
+ }
13
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 公共底座统一出口:无业务依赖的基础能力,供 config/daemon/tunnel/commands 各域使用。
3
+ *
4
+ * 目录内文件职责:
5
+ * - logger.ts: 文件日志(configureLogger / logger)
6
+ * - runtimes.ts:AI runtime 注册表、可执行文件解析与已安装探测
7
+ */
8
+ /** 文件日志 */
9
+ export { configureLogger, logger } from './logger.js';
10
+ /** AI runtime 注册表 / 命令 / prompt 参数 / 解析 / 探测 */
11
+ export * from './runtimes.js';
@@ -66,3 +66,9 @@ export const resolveRuntimeExecutable = (runtime) => {
66
66
  const candidates = entry.fallbacks ?? [`~/.local/bin/${entry.command}`];
67
67
  return candidates.map(expandHome).find(isExecutable) ?? null;
68
68
  };
69
+ /**
70
+ * 探测本机已安装的 AI runtime 名称列表。
71
+ * 过滤注册表:只保留 resolveRuntimeExecutable 能解析出可执行文件的项
72
+ * (探测与执行共用同一解析,保证“探测到”的 runtime 一定能被 spawn)。
73
+ */
74
+ export const detectInstalledRuntimes = () => RUNTIME_REGISTRY.filter((r) => resolveRuntimeExecutable(r.name) !== null).map((r) => r.name);
@@ -1,66 +1,30 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
3
2
  import { dirname, join } from 'node:path';
4
3
  import { fileURLToPath } from 'node:url';
5
- import { BRIER_DIR, PID_FILE } from '../config.js';
6
- import { logger } from '../logger.js';
4
+ import { writeCredentials } from '../config/index.js';
5
+ import { logger } from '../core/index.js';
6
+ import { isDaemonRunning, readDaemonState, removeDaemonState, waitForProcessExit, writeDaemonState, } from './state.js';
7
7
  const __filename = fileURLToPath(import.meta.url);
8
8
  const __dirname = dirname(__filename);
9
9
  const RUNNER_SCRIPT = join(__dirname, 'DaemonRunner.js');
10
- const isProcessRunning = (pid) => {
11
- try {
12
- process.kill(pid, 0);
13
- return true;
14
- }
15
- catch (err) {
16
- return err instanceof Error && 'code' in err && err.code === 'EPERM';
17
- }
18
- };
19
- const readPidFile = () => {
20
- if (!existsSync(PID_FILE))
21
- return null;
22
- try {
23
- const data = readFileSync(PID_FILE, 'utf-8');
24
- return JSON.parse(data);
25
- }
26
- catch {
27
- return null;
28
- }
29
- };
30
- const writePidFile = (data) => {
31
- mkdirSync(BRIER_DIR, { recursive: true });
32
- writeFileSync(PID_FILE, JSON.stringify(data, null, 2));
33
- };
34
- const removePidFile = () => {
35
- if (existsSync(PID_FILE)) {
36
- unlinkSync(PID_FILE);
37
- }
38
- };
39
- const waitForExit = (pid, timeoutMs = 5000) => {
40
- return new Promise((resolve) => {
41
- const startTime = Date.now();
42
- const check = () => {
43
- if (!isProcessRunning(pid)) {
44
- resolve(true);
45
- return;
46
- }
47
- if (Date.now() - startTime >= timeoutMs) {
48
- resolve(false);
49
- return;
50
- }
51
- setTimeout(check, 200);
52
- };
53
- check();
54
- });
55
- };
10
+ /** 启动后等待子进程就绪(ready/bootError)的时限 */
11
+ const READY_TIMEOUT_MS = 3_000;
12
+ const READY_POLL_MS = 150;
56
13
  export const createDaemonManager = () => {
57
14
  const start = async (options) => {
58
- const existing = readPidFile();
59
- if (existing && isProcessRunning(existing.pid)) {
15
+ const existing = readDaemonState();
16
+ if (existing && isDaemonRunning(existing)) {
60
17
  throw new Error(`Daemon is already running (PID: ${existing.pid})`);
61
18
  }
62
19
  if (existing) {
63
- removePidFile();
20
+ removeDaemonState();
21
+ }
22
+ // 持久化接入令牌(0600),供 `daemon restart` 无参回退;失败仅告警,不阻断启动
23
+ try {
24
+ writeCredentials(options.token);
25
+ }
26
+ catch (err) {
27
+ logger.warn('Failed to persist credentials:', err instanceof Error ? err.message : String(err));
64
28
  }
65
29
  const childEnv = {
66
30
  ...process.env,
@@ -77,27 +41,62 @@ export const createDaemonManager = () => {
77
41
  if (typeof child.pid !== 'number') {
78
42
  throw new Error('Failed to spawn daemon process');
79
43
  }
80
- writePidFile({
44
+ // 先写“未就绪”状态,等子进程通过 ready/bootError 完成就绪握手
45
+ const startTime = Date.now();
46
+ writeDaemonState({
81
47
  pid: child.pid,
82
- startTime: Date.now(),
48
+ startTime,
83
49
  serverUrl: options.serverUrl,
50
+ ready: false,
84
51
  });
85
- logger.info('Daemon started, PID:', child.pid);
52
+ logger.info('Daemon spawned, waiting for ready (PID:', child.pid, ')');
53
+ const deadline = Date.now() + READY_TIMEOUT_MS;
54
+ while (Date.now() < deadline) {
55
+ await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS));
56
+ const state = readDaemonState();
57
+ if (state?.ready) {
58
+ logger.info('Daemon started, PID:', child.pid);
59
+ return;
60
+ }
61
+ if (state?.bootError) {
62
+ removeDaemonState();
63
+ throw new Error(`Daemon failed to start: ${state.bootError}`);
64
+ }
65
+ }
66
+ // 超时:进程若已退出则清理并报错,否则强杀后清理
67
+ removeDaemonState();
68
+ if (!(await waitForProcessExit(child.pid, 500))) {
69
+ try {
70
+ child.kill('SIGTERM');
71
+ }
72
+ catch {
73
+ /* already gone */
74
+ }
75
+ }
76
+ throw new Error('Daemon start timed out, please check ~/.brier/daemon.log');
86
77
  };
87
78
  const stop = async () => {
88
- const data = readPidFile();
79
+ const data = readDaemonState();
89
80
  if (!data) {
90
- logger.warn('No PID file found, daemon may not be running');
81
+ logger.warn('No state file found, daemon may not be running');
91
82
  return;
92
83
  }
93
- if (!isProcessRunning(data.pid)) {
94
- logger.info('Process not running, cleaning up PID file');
95
- removePidFile();
84
+ if (!isDaemonRunning(data)) {
85
+ // 身份校验不通过:PID 已复用或进程已退出,只清理状态,不误杀其他进程
86
+ logger.warn('Daemon not running or PID reused, cleaning up state file');
87
+ removeDaemonState();
96
88
  return;
97
89
  }
98
90
  logger.info('Sending SIGTERM to PID:', data.pid);
99
- process.kill(data.pid, 'SIGTERM');
100
- const exited = await waitForExit(data.pid, 5000);
91
+ try {
92
+ process.kill(data.pid, 'SIGTERM');
93
+ }
94
+ catch {
95
+ removeDaemonState();
96
+ logger.warn('Process already exited, cleaning up');
97
+ return;
98
+ }
99
+ const exited = await waitForProcessExit(data.pid, 5000);
101
100
  if (!exited) {
102
101
  logger.warn('Process did not exit, sending SIGKILL');
103
102
  try {
@@ -107,7 +106,7 @@ export const createDaemonManager = () => {
107
106
  logger.error('Failed to kill process:', err);
108
107
  }
109
108
  }
110
- removePidFile();
109
+ removeDaemonState();
111
110
  logger.info('Daemon stopped');
112
111
  };
113
112
  const restart = async (options) => {
@@ -115,12 +114,12 @@ export const createDaemonManager = () => {
115
114
  await start(options);
116
115
  };
117
116
  const status = () => {
118
- const data = readPidFile();
117
+ const data = readDaemonState();
119
118
  if (!data)
120
119
  return 'stopped';
121
- if (isProcessRunning(data.pid))
120
+ if (isDaemonRunning(data))
122
121
  return 'running';
123
- removePidFile();
122
+ removeDaemonState();
124
123
  return 'stopped';
125
124
  };
126
125
  return { start, stop, restart, status };
@@ -1,49 +1,91 @@
1
- import { loadConfig, LOG_FILE } from '../config.js';
2
- import { configureLogger, logger } from '../logger.js';
3
- import { createTunnelClient } from '../tunnel/TunnelClient.js';
1
+ import { loadConfig, LOG_FILE } from '../config/index.js';
2
+ import { configureLogger, logger } from '../core/index.js';
3
+ import { createTunnelClient, } from '../tunnel/index.js';
4
4
  import { createTaskExecutor } from './TaskExecutor.js';
5
- let tunnel = null;
6
- const shutdown = async (signal) => {
7
- logger.info(`Received ${signal}, shutting down...`);
8
- if (tunnel) {
9
- await tunnel.stop();
10
- tunnel = null;
11
- }
12
- process.exit(0);
13
- };
14
- const handleUncaughtError = (err) => {
15
- logger.error('Uncaught error:', err.message);
16
- if (tunnel) {
17
- tunnel.stop().finally(() => process.exit(1));
18
- }
19
- else {
20
- process.exit(1);
21
- }
22
- };
23
- const run = (config) => {
5
+ import { createOutputBatcher } from './OutputBatcher.js';
6
+ import { updateDaemonState } from './state.js';
7
+ /**
8
+ * 组合根:装配并启动 daemon 的全部运行时组件。
9
+ * 纯装配职责,不注册信号、不触碰 process;进程级接线由 main 完成。
10
+ */
11
+ const startDaemon = (config) => {
12
+ // 先声明后赋值:safeSend 闭包在隧道启动后才被事件触发,此处为延迟引用
13
+ let tunnel = null;
14
+ // send() 不抛异常(未连接/背压返回 false);未发出的帧按设计丢弃(断线窗口不上行,属已知边界)
24
15
  const safeSend = (message) => {
25
- try {
26
- tunnel?.send(message);
27
- }
28
- catch {
29
- // Connection not ready, output is dropped
30
- }
16
+ tunnel?.send(message);
31
17
  };
18
+ // task-output 走批量发送(高频小消息合并,见 OutputBatcher);终态/控制消息仍即时上报
19
+ const outputBatcher = createOutputBatcher({
20
+ onFlush: (chunks) => {
21
+ for (const chunk of chunks) {
22
+ safeSend({
23
+ type: 'task-output',
24
+ taskId: chunk.taskId,
25
+ stream: chunk.stream,
26
+ data: chunk.data,
27
+ });
28
+ }
29
+ },
30
+ });
32
31
  const taskExecutor = createTaskExecutor({
33
- onOutput: (taskId, stream, data) => safeSend({ type: 'task-output', taskId, stream, data }),
34
- onComplete: (taskId, exitCode) => safeSend({ type: 'task-complete', taskId, exitCode }),
35
- onError: (taskId, error) => safeSend({ type: 'task-error', taskId, error }),
32
+ onOutput: (taskId, stream, data) => outputBatcher.push(taskId, stream, data),
33
+ onComplete: (taskId, exitCode) => {
34
+ outputBatcher.flushTask(taskId); // 终态前先发出该任务残留输出,避免被服务端终态过滤丢弃
35
+ safeSend({ type: 'task-complete', taskId, exitCode });
36
+ },
37
+ onError: (taskId, error) => {
38
+ outputBatcher.flushTask(taskId);
39
+ safeSend({ type: 'task-error', taskId, error });
40
+ },
36
41
  });
37
- tunnel = createTunnelClient(config, taskExecutor);
38
- tunnel.onStateChange((state) => {
42
+ // 组合点:把隧道下发的业务消息翻译成任务执行动作
43
+ const messageHandlers = {
44
+ onTaskStart: (message) => {
45
+ taskExecutor.execute({
46
+ taskId: message.taskId,
47
+ runtime: message.runtime,
48
+ command: message.command,
49
+ args: message.args,
50
+ cwd: message.cwd,
51
+ env: message.env,
52
+ prompt: message.prompt,
53
+ });
54
+ },
55
+ onTaskCancel: (taskId) => {
56
+ taskExecutor.cancel(taskId);
57
+ },
58
+ };
59
+ const client = createTunnelClient(config, messageHandlers);
60
+ tunnel = client;
61
+ client.onStateChange((state) => {
39
62
  logger.info('Tunnel state:', state);
63
+ // 状态落盘(低频状态切换),供 `daemon status` 展示
64
+ updateDaemonState({ tunnelState: state });
40
65
  });
41
- tunnel.start();
66
+ client.start();
67
+ updateDaemonState({ ready: true }); // 就绪握手:告知前台 Manager 启动成功
42
68
  logger.info('Daemon runner started');
43
69
  logger.info('Server:', config.serverUrl);
44
70
  logger.info('Hostname:', config.hostname);
45
71
  logger.info('OS:', config.os);
46
72
  logger.info('Runtimes:', config.runtimes.join(', ') || 'none detected');
73
+ const stop = async () => {
74
+ outputBatcher.flushAll(); // 连接关闭前先把残留的任务输出发出去
75
+ await client.stop();
76
+ await taskExecutor.dispose();
77
+ outputBatcher.dispose();
78
+ };
79
+ return { tunnel: client, taskExecutor, outputBatcher, stop };
80
+ };
81
+ const shutdown = async (ctx, signal) => {
82
+ logger.info(`Received ${signal}, shutting down...`);
83
+ try {
84
+ await ctx.stop();
85
+ }
86
+ finally {
87
+ process.exit(0);
88
+ }
47
89
  };
48
90
  const main = () => {
49
91
  if (process.env.BRIER_DAEMON_MODE !== '1') {
@@ -53,18 +95,34 @@ const main = () => {
53
95
  configureLogger(LOG_FILE);
54
96
  let config;
55
97
  try {
56
- config = loadConfig({});
98
+ config = loadConfig();
57
99
  }
58
100
  catch (err) {
59
- logger.error('Config error:', err instanceof Error ? err.message : String(err));
101
+ const message = err instanceof Error ? err.message : String(err);
102
+ updateDaemonState({ bootError: message }); // 启动失败原因回写,Manager 据此中止
103
+ logger.error('Config error:', message);
60
104
  process.exit(1);
61
105
  }
62
- process.on('SIGTERM', () => void shutdown('SIGTERM'));
63
- process.on('SIGINT', () => void shutdown('SIGINT'));
106
+ // 进程级接线:句柄只存于 main 作用域,注册信号/异常处理都显式接收它
107
+ let ctx = null;
108
+ const handleSignal = (signal) => () => {
109
+ if (ctx) {
110
+ void shutdown(ctx, signal);
111
+ }
112
+ };
113
+ const handleUncaughtError = (err) => {
114
+ logger.error('Uncaught error:', err.stack ?? err.message);
115
+ const cleanup = ctx ? ctx.stop() : Promise.resolve();
116
+ void cleanup.finally(() => process.exit(1));
117
+ };
118
+ process.on('SIGTERM', handleSignal('SIGTERM'));
119
+ process.on('SIGINT', handleSignal('SIGINT'));
64
120
  process.on('uncaughtException', handleUncaughtError);
65
121
  process.on('unhandledRejection', (reason) => {
66
122
  logger.error('Unhandled rejection:', reason);
123
+ const cleanup = ctx ? ctx.stop() : Promise.resolve();
124
+ void cleanup.finally(() => process.exit(1));
67
125
  });
68
- run(config);
126
+ ctx = startDaemon(config);
69
127
  };
70
128
  main();
@@ -0,0 +1,77 @@
1
+ const DEFAULT_MAX_BATCH_BYTES = 16 * 1024;
2
+ const DEFAULT_FLUSH_INTERVAL_MS = 50;
3
+ const keyOf = (taskId, stream) => `${taskId}\u0000${stream}`;
4
+ export const createOutputBatcher = (options) => {
5
+ const maxBatchBytes = options.maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES;
6
+ const flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
7
+ const pending = new Map();
8
+ let timer = null;
9
+ const flushEntry = (key, entry) => {
10
+ pending.delete(key);
11
+ options.onFlush([
12
+ {
13
+ taskId: entry.taskId,
14
+ stream: entry.stream,
15
+ data: entry.parts.join(''),
16
+ },
17
+ ]);
18
+ };
19
+ const ensureTimer = () => {
20
+ if (timer)
21
+ return;
22
+ timer = setInterval(() => {
23
+ if (pending.size === 0)
24
+ return;
25
+ const entries = [...pending.entries()];
26
+ for (const [key, entry] of entries) {
27
+ if (pending.has(key)) {
28
+ try {
29
+ flushEntry(key, entry);
30
+ }
31
+ catch (err) {
32
+ pending.delete(key);
33
+ // flush 回调抛错不应杀死进程(setInterval 回调中的异常成为 uncaughtException)
34
+ // 丢弃该缓冲项,其余继续
35
+ }
36
+ }
37
+ }
38
+ }, flushIntervalMs);
39
+ };
40
+ return {
41
+ push(taskId, stream, data) {
42
+ if (data.length === 0)
43
+ return;
44
+ ensureTimer();
45
+ const key = keyOf(taskId, stream);
46
+ let entry = pending.get(key);
47
+ if (!entry) {
48
+ entry = { taskId, stream, parts: [], bytes: 0 };
49
+ pending.set(key, entry);
50
+ }
51
+ entry.parts.push(data);
52
+ entry.bytes += data.length;
53
+ if (entry.bytes >= maxBatchBytes) {
54
+ flushEntry(key, entry);
55
+ }
56
+ },
57
+ flushTask(taskId) {
58
+ const entries = [...pending.entries()].filter(([, e]) => e.taskId === taskId);
59
+ for (const [key, entry] of entries) {
60
+ flushEntry(key, entry);
61
+ }
62
+ },
63
+ flushAll() {
64
+ const entries = [...pending.entries()];
65
+ for (const [key, entry] of entries) {
66
+ flushEntry(key, entry);
67
+ }
68
+ },
69
+ dispose() {
70
+ if (timer) {
71
+ clearInterval(timer);
72
+ timer = null;
73
+ }
74
+ pending.clear();
75
+ },
76
+ };
77
+ };