@coclaw/openclaw-coclaw 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openclaw.plugin.json +3 -0
- package/package.json +5 -2
- package/src/auto-upgrade/state.js +3 -3
- package/src/chat-history-manager/manager.js +4 -4
- package/src/claw-paths.js +84 -0
- package/src/config.js +3 -14
- package/src/device-identity.js +3 -15
- package/src/realtime-bridge.js +97 -17
- package/src/rpc-queue-startup.js +103 -0
- package/src/rpc-routing/run-event-routes.js +138 -0
- package/src/session-manager/manager.js +10 -5
- package/src/settings.js +2 -2
- package/src/topic-manager/manager.js +4 -5
- package/src/utils/memory-queue.js +43 -63
- package/src/webrtc/rpc-drop-monitor.js +154 -0
- package/src/webrtc/webrtc-peer.js +139 -84
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coclaw/openclaw-coclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "OpenClaw CoClaw channel plugin for remote chat",
|
|
@@ -39,7 +39,10 @@
|
|
|
39
39
|
"openclaw": {
|
|
40
40
|
"extensions": [
|
|
41
41
|
"./index.js"
|
|
42
|
-
]
|
|
42
|
+
],
|
|
43
|
+
"install": {
|
|
44
|
+
"minHostVersion": ">=2026.2.19"
|
|
45
|
+
}
|
|
43
46
|
},
|
|
44
47
|
"scripts": {
|
|
45
48
|
"build": "echo 'No build step needed (pure ES modules)'",
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* state.js — upgrade-state.json 与 upgrade-log.jsonl 读写
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 例外:本文件 gateway 主进程与 auto-upgrade worker 子进程共用,worker 没 runtime
|
|
5
|
+
* 注入,故保留独立的双轨解析(不走 claw-paths.js):
|
|
6
6
|
* 1. runtime.state.resolveStateDir()(gateway 进程内)
|
|
7
|
-
* 2. OPENCLAW_STATE_DIR 环境变量(worker
|
|
7
|
+
* 2. OPENCLAW_STATE_DIR 环境变量(worker 子进程,由 spawner 传入)
|
|
8
8
|
* 3. ~/.openclaw(兜底默认值)
|
|
9
9
|
*/
|
|
10
10
|
import fs from 'node:fs/promises';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
-
import os from 'node:os';
|
|
3
2
|
import nodePath from 'node:path';
|
|
4
3
|
|
|
4
|
+
import { agentSessionsDir } from '../claw-paths.js';
|
|
5
5
|
import { atomicWriteJsonFile } from '../utils/atomic-write.js';
|
|
6
6
|
import { createMutex } from '../utils/mutex.js';
|
|
7
7
|
|
|
@@ -28,13 +28,13 @@ function emptyStore() {
|
|
|
28
28
|
export class ChatHistoryManager {
|
|
29
29
|
/**
|
|
30
30
|
* @param {object} [opts]
|
|
31
|
-
* @param {string} [opts.rootDir] - agents 根目录,默认 ~/.openclaw/agents
|
|
32
31
|
* @param {object} [opts.logger]
|
|
32
|
+
* @param {Function} [opts.resolveSessionsDir] - 测试注入:自定义 sessions 目录解析
|
|
33
33
|
* @param {Function} [opts.readFile] - 测试注入
|
|
34
34
|
* @param {Function} [opts.writeJsonFile] - 测试注入
|
|
35
35
|
*/
|
|
36
36
|
constructor(opts = {}) {
|
|
37
|
-
this.
|
|
37
|
+
this.__resolveSessionsDir = opts.resolveSessionsDir ?? agentSessionsDir;
|
|
38
38
|
this.__logger = opts.logger ?? console;
|
|
39
39
|
/* c8 ignore next 2 -- ?? fallback:测试始终注入 */
|
|
40
40
|
this.__readFile = opts.readFile ?? fs.readFile;
|
|
@@ -48,7 +48,7 @@ export class ChatHistoryManager {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
__sessionsDir(agentId) {
|
|
51
|
-
return
|
|
51
|
+
return this.__resolveSessionsDir(agentId);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
__historyFilePath(agentId) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claw-paths.js — OpenClaw 路径解析的唯一入口(gateway 主进程内)
|
|
3
|
+
*
|
|
4
|
+
* 设计原则:
|
|
5
|
+
* - clawStateDir:高度稳定的 OpenClaw API(自 2026-02-19 起注入 runtime),直接信任
|
|
6
|
+
* - session 三件套(store / transcript / sessions dir):自 2026-03-16 起才注入 runtime,
|
|
7
|
+
* 做防御性 fallback,回退到 OpenClaw 自家长期稳定的固定布局
|
|
8
|
+
* - 不读 OPENCLAW_STATE_DIR 环境变量;不回退到 ~/.openclaw 家目录
|
|
9
|
+
* - runtime 缺失或字段缺失(除 session helper 外)即抛错,bug 早暴露
|
|
10
|
+
*
|
|
11
|
+
* 例外:auto-upgrade/state.js 是 gateway 与 worker 子进程共用的,worker 没 runtime,
|
|
12
|
+
* 故那个文件保留独立的 env 兜底,不走本模块。
|
|
13
|
+
*/
|
|
14
|
+
import nodePath from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { getRuntime } from './runtime.js';
|
|
17
|
+
|
|
18
|
+
const CHANNEL_ID = 'coclaw';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* OpenClaw 真实 state 目录
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function clawStateDir() {
|
|
25
|
+
const rt = getRuntime();
|
|
26
|
+
if (!rt?.state?.resolveStateDir) {
|
|
27
|
+
throw new Error('claw-paths: runtime not injected; cannot resolve state dir');
|
|
28
|
+
}
|
|
29
|
+
return rt.state.resolveStateDir();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* CoClaw 自管文件根目录(bindings / settings / device-identity / rpc-queues)
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function pluginDir() {
|
|
37
|
+
return nodePath.join(clawStateDir(), CHANNEL_ID);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* sessions.json 全路径(session-manager 读会话索引用)
|
|
42
|
+
*
|
|
43
|
+
* 优先 runtime helper(自 2026-03-16 起),允许跟随 OpenClaw 自定义 store 配置;
|
|
44
|
+
* runtime 没注入 helper 时回退到固定布局。
|
|
45
|
+
* @param {string} agentId
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function sessionStorePath(agentId) {
|
|
49
|
+
const rt = getRuntime();
|
|
50
|
+
const helper = rt?.agent?.session?.resolveStorePath;
|
|
51
|
+
if (helper) {
|
|
52
|
+
return helper(undefined, { agentId });
|
|
53
|
+
}
|
|
54
|
+
return nodePath.join(clawStateDir(), 'agents', agentId, 'sessions', 'sessions.json');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* sessions 所在目录(topic / chat-history 写自己的扩展文件用)
|
|
59
|
+
*
|
|
60
|
+
* 通过 sessionStorePath 反推 dirname,使 CoClaw 扩展文件随 OpenClaw 真实存储位置走。
|
|
61
|
+
* @param {string} agentId
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
export function agentSessionsDir(agentId) {
|
|
65
|
+
return nodePath.dirname(sessionStorePath(agentId));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 单条 session 的 JSONL transcript 全路径(session-manager 读单会话用)
|
|
70
|
+
* @param {string} sessionId
|
|
71
|
+
* @param {string} agentId
|
|
72
|
+
* @param {{ sessionFile?: string }} [entry] - sessions.json 索引条目,可能含 sessionFile 覆盖
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
export function sessionTranscriptPath(sessionId, agentId, entry) {
|
|
76
|
+
const rt = getRuntime();
|
|
77
|
+
const helper = rt?.agent?.session?.resolveSessionFilePath;
|
|
78
|
+
if (helper) {
|
|
79
|
+
return helper(sessionId, entry, { agentId });
|
|
80
|
+
}
|
|
81
|
+
return nodePath.join(agentSessionsDir(agentId), `${sessionId}.jsonl`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export { CHANNEL_ID };
|
package/src/config.js
CHANGED
|
@@ -1,27 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
-
import os from 'node:os';
|
|
3
2
|
import nodePath from 'node:path';
|
|
4
3
|
|
|
5
|
-
import {
|
|
4
|
+
import { CHANNEL_ID, pluginDir } from './claw-paths.js';
|
|
6
5
|
import { atomicWriteJsonFile } from './utils/atomic-write.js';
|
|
7
6
|
import { createMutex } from './utils/mutex.js';
|
|
8
7
|
|
|
9
8
|
export const DEFAULT_ACCOUNT_ID = 'default';
|
|
10
|
-
export
|
|
9
|
+
export { CHANNEL_ID };
|
|
11
10
|
const BINDINGS_FILENAME = 'bindings.json';
|
|
12
11
|
|
|
13
|
-
export function resolveStateDir() {
|
|
14
|
-
const rt = getRuntime();
|
|
15
|
-
if (rt?.state?.resolveStateDir) {
|
|
16
|
-
return rt.state.resolveStateDir();
|
|
17
|
-
}
|
|
18
|
-
return process.env.OPENCLAW_STATE_DIR
|
|
19
|
-
? nodePath.resolve(process.env.OPENCLAW_STATE_DIR)
|
|
20
|
-
: nodePath.join(os.homedir(), '.openclaw');
|
|
21
|
-
}
|
|
22
|
-
|
|
23
12
|
export function getBindingsPath() {
|
|
24
|
-
return nodePath.join(
|
|
13
|
+
return nodePath.join(pluginDir(), BINDINGS_FILENAME);
|
|
25
14
|
}
|
|
26
15
|
|
|
27
16
|
function toRecord(value) {
|
package/src/device-identity.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
-
import os from 'node:os';
|
|
4
3
|
import nodePath from 'node:path';
|
|
5
4
|
|
|
6
|
-
import {
|
|
5
|
+
import { pluginDir } from './claw-paths.js';
|
|
7
6
|
import { atomicWriteFileSync } from './utils/atomic-write.js';
|
|
8
7
|
|
|
9
|
-
const CHANNEL_ID = 'coclaw';
|
|
10
8
|
const IDENTITY_FILENAME = 'device-identity.json';
|
|
11
9
|
|
|
12
10
|
// Ed25519 SPKI 前缀(固定 12 字节),公钥裸字节从 SPKI DER 中截取
|
|
@@ -27,22 +25,12 @@ function normalizeMetadataForAuth(value) {
|
|
|
27
25
|
return trimmed ? toLowerAscii(trimmed) : '';
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
function resolveStateDir() {
|
|
31
|
-
const rt = getRuntime();
|
|
32
|
-
if (rt?.state?.resolveStateDir) {
|
|
33
|
-
return rt.state.resolveStateDir();
|
|
34
|
-
}
|
|
35
|
-
return process.env.OPENCLAW_STATE_DIR
|
|
36
|
-
? nodePath.resolve(process.env.OPENCLAW_STATE_DIR)
|
|
37
|
-
: nodePath.join(os.homedir(), '.openclaw');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
28
|
/**
|
|
41
29
|
* 获取身份文件路径
|
|
42
30
|
* @returns {string}
|
|
43
31
|
*/
|
|
44
32
|
export function getIdentityPath() {
|
|
45
|
-
return nodePath.join(
|
|
33
|
+
return nodePath.join(pluginDir(), IDENTITY_FILENAME);
|
|
46
34
|
}
|
|
47
35
|
|
|
48
36
|
/**
|
|
@@ -89,7 +77,7 @@ function generateIdentity() {
|
|
|
89
77
|
* 加载或创建设备身份(Ed25519 密钥对)
|
|
90
78
|
*
|
|
91
79
|
* 存储格式与 OpenClaw device-identity.ts 保持一致。
|
|
92
|
-
* @param {string} [filePath] - 自定义路径,默认
|
|
80
|
+
* @param {string} [filePath] - 自定义路径,默认 <state-dir>/coclaw/device-identity.json
|
|
93
81
|
* @returns {{ deviceId: string, publicKeyPem: string, privateKeyPem: string }}
|
|
94
82
|
*/
|
|
95
83
|
export function loadOrCreateDeviceIdentity(filePath) {
|
package/src/realtime-bridge.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import os from 'node:os';
|
|
3
1
|
import nodePath from 'node:path';
|
|
4
2
|
import { WebSocket as WsWebSocket } from 'ws';
|
|
5
3
|
|
|
4
|
+
import { pluginDir } from './claw-paths.js';
|
|
6
5
|
import { clearConfig, getBindingsPath, readConfig } from './config.js';
|
|
6
|
+
import { cleanupResiduals as defaultCleanupResiduals, measureDiskCap as defaultMeasureDiskCap } from './rpc-queue-startup.js';
|
|
7
7
|
import { getHostName, readSettings } from './settings.js';
|
|
8
8
|
import {
|
|
9
9
|
loadOrCreateDeviceIdentity,
|
|
@@ -15,6 +15,7 @@ import { getRuntime } from './runtime.js';
|
|
|
15
15
|
import { setSender as setRemoteLogSender, remoteLog } from './remote-log.js';
|
|
16
16
|
import { getPluginVersion } from './plugin-version.js';
|
|
17
17
|
import { getPlatformInfoLine } from './platform-info.js';
|
|
18
|
+
import { RunEventRoutes, DEFAULT_TTL_MS as RUN_EVENT_DEFAULT_TTL_MS, DEFAULT_SCAN_MS as RUN_EVENT_DEFAULT_SCAN_MS } from './rpc-routing/run-event-routes.js';
|
|
18
19
|
|
|
19
20
|
const DEFAULT_GATEWAY_WS_URL = `ws://127.0.0.1:${process.env.OPENCLAW_GATEWAY_PORT || '18789'}`;
|
|
20
21
|
const RECONNECT_MS = 10_000;
|
|
@@ -87,24 +88,18 @@ function maskUrlToken(url) {
|
|
|
87
88
|
return url.replace(/([?&]token=)[^&]+/, '$1***');
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
|
|
91
|
-
function defaultResolveGatewayAuthToken() {
|
|
91
|
+
// 仅在未注入 resolveGatewayAuthToken 时使用,依赖 runtime / 环境变量
|
|
92
|
+
export function defaultResolveGatewayAuthToken() {
|
|
92
93
|
const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim();
|
|
93
94
|
if (envToken) {
|
|
94
95
|
return envToken;
|
|
95
96
|
}
|
|
96
97
|
try {
|
|
97
98
|
const rt = getRuntime();
|
|
98
|
-
if (rt?.config?.loadConfig) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
const cfgPath = process.env.OPENCLAW_CONFIG_PATH
|
|
104
|
-
? nodePath.resolve(process.env.OPENCLAW_CONFIG_PATH)
|
|
105
|
-
: nodePath.join(os.homedir(), '.openclaw', 'openclaw.json');
|
|
106
|
-
const raw = fs.readFileSync(cfgPath, 'utf8');
|
|
107
|
-
const cfg = JSON.parse(raw);
|
|
99
|
+
if (!rt?.config?.loadConfig) {
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
const cfg = rt.config.loadConfig();
|
|
108
103
|
const token = cfg?.gateway?.auth?.token;
|
|
109
104
|
return typeof token === 'string' && token.trim() ? token.trim() : '';
|
|
110
105
|
}
|
|
@@ -113,7 +108,6 @@ function defaultResolveGatewayAuthToken() {
|
|
|
113
108
|
return '';
|
|
114
109
|
}
|
|
115
110
|
}
|
|
116
|
-
/* c8 ignore stop */
|
|
117
111
|
|
|
118
112
|
/**
|
|
119
113
|
* WebSocket 桥接器:CoClaw server ↔ OpenClaw gateway
|
|
@@ -132,6 +126,8 @@ export class RealtimeBridge {
|
|
|
132
126
|
* @param {number} [deps.gatewayReadyTimeoutMs] - __waitGatewayReady 默认超时(测试可注入短值)
|
|
133
127
|
* @param {number} [deps.dcReqTtlMs] - UI 转发 RPC 路由表条目 TTL(测试可注入短值)
|
|
134
128
|
* @param {number} [deps.dcReqScanMs] - UI 转发 RPC 路由表周期扫描间隔(测试可注入短值)
|
|
129
|
+
* @param {number} [deps.runEventRoutesTtlMs] - runId → connId 路由表条目 TTL(测试可注入短值)
|
|
130
|
+
* @param {number} [deps.runEventRoutesScanMs] - runId → connId 路由表周期扫描间隔(测试可注入短值)
|
|
135
131
|
*/
|
|
136
132
|
constructor(deps = {}) {
|
|
137
133
|
this.__readConfig = deps.readConfig ?? readConfig;
|
|
@@ -145,6 +141,11 @@ export class RealtimeBridge {
|
|
|
145
141
|
this.__gatewayReadyTimeoutMs = deps.gatewayReadyTimeoutMs ?? 1500;
|
|
146
142
|
this.__dcReqTtlMs = deps.dcReqTtlMs ?? DC_REQ_TTL_MS;
|
|
147
143
|
this.__dcReqScanMs = deps.dcReqScanMs ?? DC_REQ_SCAN_MS;
|
|
144
|
+
this.__runEventRoutesTtlMs = deps.runEventRoutesTtlMs ?? RUN_EVENT_DEFAULT_TTL_MS;
|
|
145
|
+
this.__runEventRoutesScanMs = deps.runEventRoutesScanMs ?? RUN_EVENT_DEFAULT_SCAN_MS;
|
|
146
|
+
// rpc-queues/ 启动期预热钩子(B-stage1 plan-2)。仅供测试覆盖错误分支注入;生产路径走默认。
|
|
147
|
+
this.__cleanupRpcQueueResiduals = deps.cleanupRpcQueueResiduals ?? defaultCleanupResiduals;
|
|
148
|
+
this.__measureRpcQueueDiskCap = deps.measureRpcQueueDiskCap ?? defaultMeasureDiskCap;
|
|
148
149
|
|
|
149
150
|
this.serverWs = null;
|
|
150
151
|
this.gatewayWs = null;
|
|
@@ -179,6 +180,11 @@ export class RealtimeBridge {
|
|
|
179
180
|
// 用于 res 帧按发起方单播;查不到时回退广播兜底(兼容旧 UI / 撞号 / 上游新增中间态字符串等)
|
|
180
181
|
this.__dcPendingRequests = new Map();
|
|
181
182
|
this.__dcPendingScanTimer = null;
|
|
183
|
+
// runId → connId 路由表:用于 event:agent 帧按发起方单播。
|
|
184
|
+
// 实例延迟到 start() 真 logger 到位时再 new;stop() destroy 后置 null。
|
|
185
|
+
this.__runEventRoutes = null;
|
|
186
|
+
// rpc DC 文件回退队列的磁盘容量(B-stage1 plan-2 探测,B-stage2 才消费)
|
|
187
|
+
this.__diskCap = null;
|
|
182
188
|
}
|
|
183
189
|
|
|
184
190
|
__resolveWebSocket() {
|
|
@@ -287,6 +293,8 @@ export class RealtimeBridge {
|
|
|
287
293
|
this.gatewayPendingRequests.clear();
|
|
288
294
|
// 清空 UI 转发 RPC 路由表:gateway 已断,不会再有响应回来;不主动通知 UI,由 UI 30/60s 超时兜底
|
|
289
295
|
this.__dcPendingRequests.clear();
|
|
296
|
+
// 同步清空 runId 路由表(gateway 已断,不会再有 event:agent 推过来)
|
|
297
|
+
this.__runEventRoutes?.clear();
|
|
290
298
|
}
|
|
291
299
|
|
|
292
300
|
/** 懒加载 WebRtcPeer(promise 锁防并发重复创建) */
|
|
@@ -842,10 +850,28 @@ export class RealtimeBridge {
|
|
|
842
850
|
if (payload.type === 'res' && typeof payload.id === 'string') {
|
|
843
851
|
const info = this.__dcPendingRequests.get(payload.id);
|
|
844
852
|
if (info) {
|
|
845
|
-
|
|
853
|
+
// runId 路由表维护:accepted 时 add(首发优先),非 accepted 时 remove。
|
|
854
|
+
// 写入要求 reqId 表命中以拿 connId;删除嵌在 reqId 命中分支内——
|
|
855
|
+
// 因 reqId 表 miss 意味着写入也未发生过(设计上等价 no-op),
|
|
856
|
+
// 极端错位(reqId 表先 TTL 过期、runId 表条目仍存)由 24h TTL 兜底。
|
|
857
|
+
const runId = payload.payload?.runId;
|
|
858
|
+
if (typeof runId === 'string' && runId) {
|
|
859
|
+
if (payload.payload?.status === 'accepted') {
|
|
860
|
+
this.__runEventRoutes?.add(runId, info.connId, payload.id);
|
|
861
|
+
this.logger.debug?.(`[coclaw/run-event-route] add runId=${runId} connId=${info.connId} reqId=${payload.id}`);
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
this.__runEventRoutes?.remove(runId, payload.id);
|
|
865
|
+
this.logger.debug?.(`[coclaw/run-event-route] remove runId=${runId} reqId=${payload.id}`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
// 终态才清条目;accepted 类中间态保留等下一帧
|
|
846
869
|
if (isFinalResMsg(payload)) {
|
|
847
870
|
this.__dcPendingRequests.delete(payload.id);
|
|
871
|
+
this.logger.debug?.(`[coclaw/rpc-res-route] remove reqId=${payload.id} reason=final-res`);
|
|
848
872
|
}
|
|
873
|
+
/* c8 ignore next -- TODO: 2026-05-20 后删除 */
|
|
874
|
+
this.logger.debug?.(`[coclaw/rpc-res-route] hit, reqId=${payload.id} → connId=${info.connId}`);
|
|
849
875
|
// sendTo 阶段 1 改为 async(admission 决策 await);外层 listener 已是 async
|
|
850
876
|
const delivered = await this.webrtcPeer?.sendTo(info.connId, payload);
|
|
851
877
|
if (!delivered) {
|
|
@@ -856,6 +882,24 @@ export class RealtimeBridge {
|
|
|
856
882
|
}
|
|
857
883
|
return;
|
|
858
884
|
}
|
|
885
|
+
/* c8 ignore next -- TODO: 2026-05-20 后删除 */
|
|
886
|
+
this.logger.debug?.(`[coclaw/rpc-res-route] miss, broadcast, reqId=${payload.id}`);
|
|
887
|
+
}
|
|
888
|
+
// (c2) agent event 按 runId 单播:命中即送达,不退兜底广播;miss 走 (d) 兜底
|
|
889
|
+
if (payload.type === 'event' && payload.event === 'agent') {
|
|
890
|
+
const runId = payload.payload?.runId;
|
|
891
|
+
if (typeof runId === 'string' && runId) {
|
|
892
|
+
const connId = this.__runEventRoutes?.lookup(runId);
|
|
893
|
+
if (connId !== undefined) {
|
|
894
|
+
/* c8 ignore next -- TODO: 2026-05-20 后删除 */
|
|
895
|
+
this.logger.debug?.(`[coclaw/run-event-route] hit, runId=${runId} → connId=${connId}`);
|
|
896
|
+
// sendTo 失败不打 log(PC 状态翻转日志已足够,drop 是正确语义)
|
|
897
|
+
await this.webrtcPeer?.sendTo(connId, payload);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
/* c8 ignore next -- TODO: 2026-05-20 后删除 */
|
|
902
|
+
this.logger.debug?.(`[coclaw/run-event-route] miss, broadcast, runId=${runId ?? '<missing>'}`);
|
|
859
903
|
}
|
|
860
904
|
// (d) 兜底广播:覆盖 event 类型 / 映射未命中场景
|
|
861
905
|
this.webrtcPeer?.broadcast(payload);
|
|
@@ -894,6 +938,8 @@ export class RealtimeBridge {
|
|
|
894
938
|
this.gatewayPendingRequests.clear();
|
|
895
939
|
// 同步清空 UI 转发 RPC 路由表(同 __closeGatewayWs 语义)
|
|
896
940
|
this.__dcPendingRequests.clear();
|
|
941
|
+
// 同步清空 runId 路由表(gateway 已断,不会再有 event:agent 推过来)
|
|
942
|
+
this.__runEventRoutes?.clear();
|
|
897
943
|
// 调度下一次尝试:仅在 bridge 仍活着、未 gave-up、server WS 健康时;
|
|
898
944
|
// 其他场景(如 bridge stop、server WS 已断)由上游流程兜底,不参与 gateway 重试。
|
|
899
945
|
if (this.started && !this.__gatewayGaveUp
|
|
@@ -1013,6 +1059,7 @@ export class RealtimeBridge {
|
|
|
1013
1059
|
connId,
|
|
1014
1060
|
expireAt: Date.now() + this.__dcReqTtlMs,
|
|
1015
1061
|
});
|
|
1062
|
+
this.logger.debug?.(`[coclaw/rpc-res-route] add reqId=${id} connId=${connId}`);
|
|
1016
1063
|
}
|
|
1017
1064
|
try {
|
|
1018
1065
|
this.__logDebug(`gateway req -> id=${id} method=${payload.method}`);
|
|
@@ -1030,7 +1077,10 @@ export class RealtimeBridge {
|
|
|
1030
1077
|
catch {
|
|
1031
1078
|
// SEND_FAILED:撤回映射后广播错误响应
|
|
1032
1079
|
if (typeof id === 'string') {
|
|
1033
|
-
this.__dcPendingRequests.delete(id);
|
|
1080
|
+
const removed = this.__dcPendingRequests.delete(id);
|
|
1081
|
+
if (removed) {
|
|
1082
|
+
this.logger.debug?.(`[coclaw/rpc-res-route] remove reqId=${id} reason=send-failed`);
|
|
1083
|
+
}
|
|
1034
1084
|
}
|
|
1035
1085
|
this.webrtcPeer?.broadcast({
|
|
1036
1086
|
type: 'res',
|
|
@@ -1336,6 +1386,24 @@ export class RealtimeBridge {
|
|
|
1336
1386
|
this.logger = logger ?? console;
|
|
1337
1387
|
this.pluginConfig = pluginConfig ?? {};
|
|
1338
1388
|
this.started = true;
|
|
1389
|
+
// rpc DC 文件回退队列的启动期预热(B-stage1 plan-2):清残留 *.jsonl + 探测磁盘容量。
|
|
1390
|
+
// 远早于第一条 rpc DC 建立(dump 设计);__diskCap 暂存供 B-stage2 切 FBQ 时取用。
|
|
1391
|
+
// 整块包 try/catch:模块自身不抛,但仍可能进入 catch 的路径——pluginDir() 同步抛
|
|
1392
|
+
// (runtime 未注入 / nodePath.join 参数异常 / 测试注入的 stub 抛错)。任何路径都不能把
|
|
1393
|
+
// bridge.start 卡死。
|
|
1394
|
+
try {
|
|
1395
|
+
const queueDir = nodePath.join(pluginDir(), 'rpc-queues');
|
|
1396
|
+
await this.__cleanupRpcQueueResiduals(queueDir, { logger: this.logger });
|
|
1397
|
+
this.__diskCap = await this.__measureRpcQueueDiskCap(queueDir, { logger: this.logger });
|
|
1398
|
+
}
|
|
1399
|
+
catch (err) {
|
|
1400
|
+
/* c8 ignore next -- ?./?? fallback:err 总是 Error,logger.warn 总存在 */
|
|
1401
|
+
this.logger.warn?.(`[coclaw] rpc-queues startup prep failed (skipped): ${err?.message ?? err}`);
|
|
1402
|
+
this.__diskCap = null;
|
|
1403
|
+
}
|
|
1404
|
+
// race 守卫:cleanup/measure 期间若 stop() 已执行,不应再启动 native WebRTC 进程。
|
|
1405
|
+
// preload 后还有一道 started 检查兜底(含 pion cleanup),这里先挡住一次无意义的 preload。
|
|
1406
|
+
if (!this.started) return;
|
|
1339
1407
|
// 先完成 WebRTC 实现加载,再建立连接,避免 UI 发来 offer 时 RTC 包未就绪
|
|
1340
1408
|
// 优先级:pion → ndc → werift → none
|
|
1341
1409
|
const preloadResult = await this.__preloadWebrtc();
|
|
@@ -1378,6 +1446,13 @@ export class RealtimeBridge {
|
|
|
1378
1446
|
}
|
|
1379
1447
|
}, this.__dcReqScanMs);
|
|
1380
1448
|
this.__dcPendingScanTimer.unref?.();
|
|
1449
|
+
// 启动 runId → connId 路由表(agent event 单播)。延迟到 start 才 new,确保拿到真 logger。
|
|
1450
|
+
this.__runEventRoutes = new RunEventRoutes({
|
|
1451
|
+
logger: this.logger,
|
|
1452
|
+
ttlMs: this.__runEventRoutesTtlMs,
|
|
1453
|
+
scanMs: this.__runEventRoutesScanMs,
|
|
1454
|
+
});
|
|
1455
|
+
this.__runEventRoutes.init();
|
|
1381
1456
|
await this.__connectIfNeeded();
|
|
1382
1457
|
}
|
|
1383
1458
|
|
|
@@ -1428,6 +1503,11 @@ export class RealtimeBridge {
|
|
|
1428
1503
|
clearInterval(this.__dcPendingScanTimer);
|
|
1429
1504
|
this.__dcPendingScanTimer = null;
|
|
1430
1505
|
}
|
|
1506
|
+
// 销毁 runId 路由表(停 timer + clear + 标 destroyed);refresh 时会重建
|
|
1507
|
+
if (this.__runEventRoutes) {
|
|
1508
|
+
this.__runEventRoutes.destroy();
|
|
1509
|
+
this.__runEventRoutes = null;
|
|
1510
|
+
}
|
|
1431
1511
|
this.__closeGatewayWs();
|
|
1432
1512
|
if (this.webrtcPeer) {
|
|
1433
1513
|
await this.webrtcPeer.closeAll().catch(() => {});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rpc-queues/ 启动期预热(B-stage1 plan-2)。
|
|
3
|
+
*
|
|
4
|
+
* 提供两个 async 函数,均**永不抛**——bridge.start 不能被启动期 fs 操作阻断:
|
|
5
|
+
*
|
|
6
|
+
* - `cleanupResiduals(dir, opts)`:mkdir { recursive: true } → readdir →
|
|
7
|
+
* 按 `*.jsonl` 白名单逐个 unlink。任何子步失败均 warn 并跳过。
|
|
8
|
+
* 白名单确保不会误删邻近文件(dump 设计:保留账本类小文件的扩展位)。
|
|
9
|
+
*
|
|
10
|
+
* - `measureDiskCap(dir, opts)`:fs.statfs → 公式
|
|
11
|
+
* `min(1GB, max(64MB, floor(free × 0.5)))`;statfs 抛错或缺失(Node <18.15)
|
|
12
|
+
* 走 catch 路径回退固定 1GB。返回值由 bridge 暂存到 `__diskCap`,
|
|
13
|
+
* B-stage2 切 FBQ 时再消费(路径 TBD)。
|
|
14
|
+
*
|
|
15
|
+
* `fsOps` 注入仅供测试覆盖错误分支;生产路径默认 `fs.promises`,调用方不传。
|
|
16
|
+
*
|
|
17
|
+
* 行为契约(红线):
|
|
18
|
+
* - 不抛错,只 warn
|
|
19
|
+
* - 不递归删——白名单仅 `*.jsonl`
|
|
20
|
+
* - statfs 失败/缺失统一回退 1GB
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs/promises';
|
|
24
|
+
import nodePath from 'node:path';
|
|
25
|
+
|
|
26
|
+
export const ONE_GB = 1024 * 1024 * 1024;
|
|
27
|
+
export const SIXTY_FOUR_MB = 64 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} dir - 队列目录绝对路径
|
|
31
|
+
* @param {object} [opts]
|
|
32
|
+
* @param {object} [opts.logger] - pino 风格 logger(warn? / info? / error?)
|
|
33
|
+
* @param {object} [opts.fsOps] - fs.promises 兼容子集(mkdir/readdir/unlink),仅供测试
|
|
34
|
+
*/
|
|
35
|
+
export async function cleanupResiduals(dir, { logger, fsOps = fs } = {}) {
|
|
36
|
+
try {
|
|
37
|
+
await fsOps.mkdir(dir, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
/* c8 ignore next -- ?./?? fallback:err 总是 Error,.message 总存在 */
|
|
41
|
+
logger?.warn?.(`[coclaw] rpc-queues cleanup mkdir failed: ${err?.message ?? err}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let names;
|
|
46
|
+
try {
|
|
47
|
+
names = await fsOps.readdir(dir);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
/* c8 ignore next -- ?./?? fallback:err 总是 Error,.message 总存在 */
|
|
51
|
+
logger?.warn?.(`[coclaw] rpc-queues cleanup readdir failed: ${err?.message ?? err}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
// readdir 默认返回 string[],但若调用方注入 Buffer/Dirent 风格 mock,name.endsWith
|
|
57
|
+
// 会抛出冲过"模块永不抛"红线。生产路径不会触发——纯防御。
|
|
58
|
+
if (typeof name !== 'string') {
|
|
59
|
+
logger?.warn?.(`[coclaw] rpc-queues unexpected non-string entry: ${typeof name}`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
63
|
+
// nodePath.join 与 unlink 共享同一 try/catch:dir 若误传非 string(生产路径不会,
|
|
64
|
+
// 但 typeof readdir 防御已挡 name 那一头),nodePath.join(dir, name) 会抛 TypeError,
|
|
65
|
+
// 必须落在同一个 catch 里兜住才不破"模块永不抛"红线。
|
|
66
|
+
try {
|
|
67
|
+
const p = nodePath.join(dir, name);
|
|
68
|
+
await fsOps.unlink(p);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
/* c8 ignore next -- ?./?? fallback:err 总是 Error,.message 总存在 */
|
|
72
|
+
logger?.warn?.(`[coclaw] rpc-queues unlink failed file=${name} err=${err?.message ?? err}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} dir - 队列目录绝对路径(statfs 自动定位所在文件系统)
|
|
79
|
+
* @param {object} [opts]
|
|
80
|
+
* @param {object} [opts.logger]
|
|
81
|
+
* @param {object} [opts.fsOps] - fs.promises 兼容子集(statfs),仅供测试
|
|
82
|
+
* @returns {Promise<number>} diskCap 字节数;statfs 失败回退 1GB
|
|
83
|
+
*/
|
|
84
|
+
export async function measureDiskCap(dir, { logger, fsOps = fs } = {}) {
|
|
85
|
+
try {
|
|
86
|
+
const st = await fsOps.statfs(dir);
|
|
87
|
+
const free = Number(st.bavail) * Number(st.bsize);
|
|
88
|
+
// 真实生产环境(容器、网络挂载、ENOSYS 走 catch 之外的怪环境)下 statfs 偶有
|
|
89
|
+
// 返回非 number / NaN / 负数字段的情况;Number(NaN/undefined) 乘任何东西都是 NaN,
|
|
90
|
+
// floor(NaN * 0.5) = NaN,max/min 链路也会冒泡 NaN——不防御会让 __diskCap 为 NaN。
|
|
91
|
+
if (!Number.isFinite(free) || free < 0) {
|
|
92
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
93
|
+
logger?.warn?.(`[coclaw] rpc-queues statfs failed (non-finite, fallback 1GB): bavail=${st?.bavail} bsize=${st?.bsize}`);
|
|
94
|
+
return ONE_GB;
|
|
95
|
+
}
|
|
96
|
+
return Math.min(ONE_GB, Math.max(SIXTY_FOUR_MB, Math.floor(free * 0.5)));
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
/* c8 ignore next -- ?./?? fallback:err 总是 Error 或 TypeError,.message 总存在 */
|
|
100
|
+
logger?.warn?.(`[coclaw] rpc-queues statfs failed (fallback 1GB): ${err?.message ?? err}`);
|
|
101
|
+
return ONE_GB;
|
|
102
|
+
}
|
|
103
|
+
}
|