@xmoxmo/bncr 0.1.0 → 0.1.2
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 +49 -1
- package/index.ts +308 -64
- package/package.json +3 -2
- package/scripts/check-register-drift.mjs +105 -0
- package/src/channel.ts +375 -2
package/README.md
CHANGED
|
@@ -34,6 +34,14 @@ openclaw plugins update bncr
|
|
|
34
34
|
openclaw gateway restart
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
> 兼容范围:`openclaw >= 2026.3.22`
|
|
38
|
+
>
|
|
39
|
+
> 如果你是从精确版本升级,或本地安装记录仍钉在旧版本,也可以显式执行:
|
|
40
|
+
>
|
|
41
|
+
> ```bash
|
|
42
|
+
> openclaw plugins install @xmoxmo/bncr@0.1.1
|
|
43
|
+
> openclaw gateway restart
|
|
44
|
+
> ```
|
|
37
45
|
|
|
38
46
|
### Bncr / 无界侧
|
|
39
47
|
|
|
@@ -137,7 +145,35 @@ openclaw health --json
|
|
|
137
145
|
|
|
138
146
|
---
|
|
139
147
|
|
|
140
|
-
## 8.
|
|
148
|
+
## 8. 常见安装/加载问题
|
|
149
|
+
|
|
150
|
+
### 报错:`Cannot find module 'openclaw/plugin-sdk/core'`
|
|
151
|
+
|
|
152
|
+
这通常不是 bncr 没装上,而是:
|
|
153
|
+
|
|
154
|
+
- bncr 已经安装到 `~/.openclaw/extensions/bncr`
|
|
155
|
+
- 但插件目录当前解析不到宿主 `openclaw` 包
|
|
156
|
+
- 因而在加载 `openclaw/plugin-sdk/core` 时失败
|
|
157
|
+
|
|
158
|
+
bncr 0.1.1 会先尝试自动修复插件目录下的 `node_modules/openclaw` 解析链;如果仍失败,可手动执行:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
mkdir -p ~/.openclaw/extensions/bncr/node_modules
|
|
162
|
+
ln -s "$(npm root -g)/openclaw" ~/.openclaw/extensions/bncr/node_modules/openclaw
|
|
163
|
+
openclaw gateway restart
|
|
164
|
+
openclaw plugins inspect bncr
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
如果 `npm root -g` 指向的不是实际宿主位置,请先检查:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
which openclaw
|
|
171
|
+
npm root -g
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
然后把 `openclaw` 的真实安装目录软链接到 `~/.openclaw/extensions/bncr/node_modules/openclaw`。
|
|
175
|
+
|
|
176
|
+
## 9. 自检与测试
|
|
141
177
|
|
|
142
178
|
```bash
|
|
143
179
|
cd plugins/bncr
|
|
@@ -151,6 +187,18 @@ npm pack
|
|
|
151
187
|
- `npm test`:跑回归测试
|
|
152
188
|
- `npm run selfcheck`:检查插件骨架是否完整
|
|
153
189
|
- `npm pack`:确认当前版本可正常打包
|
|
190
|
+
- `npm run check-register-drift -- --duration-sec 300 --interval-sec 15`:静置采样 `bncr.diagnostics`,观察 `registerCount / apiGeneration / postWarmupRegisterCount` 是否在 warmup 后继续增长
|
|
191
|
+
|
|
192
|
+
示例输出重点:
|
|
193
|
+
|
|
194
|
+
- `delta.registerCount`
|
|
195
|
+
- `delta.apiGeneration`
|
|
196
|
+
- `delta.postWarmupRegisterCount`
|
|
197
|
+
- `historicalWarmupExternalDrift`
|
|
198
|
+
- `newDriftDuringWindow`
|
|
199
|
+
- `last.postWarmupRegisterCount`
|
|
200
|
+
- `last.unexpectedRegisterAfterWarmup`
|
|
201
|
+
- `driftDetected`
|
|
154
202
|
|
|
155
203
|
---
|
|
156
204
|
|
package/index.ts
CHANGED
|
@@ -1,31 +1,292 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
} from
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { BncrConfigSchema } from './src/core/config-schema.ts';
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
const pluginFile = fileURLToPath(import.meta.url);
|
|
9
|
+
const pluginDir = path.dirname(pluginFile);
|
|
10
|
+
const pluginRequire = createRequire(import.meta.url);
|
|
11
|
+
const sdkCoreSpecifier = 'openclaw/plugin-sdk/core';
|
|
12
|
+
const linkType = process.platform === 'win32' ? 'junction' : 'dir';
|
|
13
|
+
|
|
14
|
+
type ChannelModule = typeof import('./src/channel.ts');
|
|
15
|
+
type OpenClawPluginApi = Parameters<ChannelModule['createBncrBridge']>[0];
|
|
16
|
+
type BridgeSingleton = ReturnType<ChannelModule['createBncrBridge']>;
|
|
17
|
+
|
|
18
|
+
type LoadedRuntime = {
|
|
19
|
+
createBncrBridge: ChannelModule['createBncrBridge'];
|
|
20
|
+
createBncrChannelPlugin: ChannelModule['createBncrChannelPlugin'];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const BNCR_REGISTER_META = Symbol.for('bncr.register.meta');
|
|
24
|
+
|
|
25
|
+
type RegisterMeta = {
|
|
26
|
+
service?: boolean;
|
|
27
|
+
channel?: boolean;
|
|
28
|
+
methods?: Set<string>;
|
|
29
|
+
apiInstanceId?: string;
|
|
30
|
+
registryFingerprint?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type OpenClawPluginApiWithMeta = OpenClawPluginApi & {
|
|
34
|
+
[BNCR_REGISTER_META]?: RegisterMeta;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let runtime: LoadedRuntime | null = null;
|
|
38
|
+
const identityIds = new WeakMap<object, string>();
|
|
39
|
+
let identitySeq = 0;
|
|
40
|
+
|
|
41
|
+
const tryExec = (command: string, args: string[]) => {
|
|
42
|
+
try {
|
|
43
|
+
return execFileSync(command, args, {
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
46
|
+
}).trim();
|
|
47
|
+
} catch {
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const readOpenClawPackageName = (pkgPath: string) => {
|
|
53
|
+
try {
|
|
54
|
+
const raw = fs.readFileSync(pkgPath, 'utf8');
|
|
55
|
+
const parsed = JSON.parse(raw);
|
|
56
|
+
return typeof parsed?.name === 'string' ? parsed.name : '';
|
|
57
|
+
} catch {
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const findOpenClawPackageRoot = (startPath: string) => {
|
|
63
|
+
let current = startPath;
|
|
64
|
+
try {
|
|
65
|
+
current = fs.realpathSync(startPath);
|
|
66
|
+
} catch {
|
|
67
|
+
// keep original path when realpath fails
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let cursor = current;
|
|
71
|
+
while (true) {
|
|
72
|
+
const statPath = fs.existsSync(cursor) ? cursor : path.dirname(cursor);
|
|
73
|
+
const pkgPath = path.join(statPath, 'package.json');
|
|
74
|
+
if (fs.existsSync(pkgPath) && readOpenClawPackageName(pkgPath) === 'openclaw') {
|
|
75
|
+
return statPath;
|
|
76
|
+
}
|
|
77
|
+
const parent = path.dirname(statPath);
|
|
78
|
+
if (parent === statPath) break;
|
|
79
|
+
cursor = parent;
|
|
80
|
+
}
|
|
81
|
+
return '';
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const unique = (items: string[]) => {
|
|
85
|
+
const seen = new Set<string>();
|
|
86
|
+
const out: string[] = [];
|
|
87
|
+
for (const item of items) {
|
|
88
|
+
if (!item) continue;
|
|
89
|
+
const normalized = path.normalize(item);
|
|
90
|
+
if (seen.has(normalized)) continue;
|
|
91
|
+
seen.add(normalized);
|
|
92
|
+
out.push(normalized);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const collectOpenClawCandidates = () => {
|
|
98
|
+
const directCandidates = [
|
|
99
|
+
path.join(pluginDir, 'node_modules', 'openclaw'),
|
|
100
|
+
path.join('/usr/lib/node_modules', 'openclaw'),
|
|
101
|
+
path.join('/usr/local/lib/node_modules', 'openclaw'),
|
|
102
|
+
path.join('/opt/homebrew/lib/node_modules', 'openclaw'),
|
|
103
|
+
path.join(process.env.HOME || '', '.npm-global/lib/node_modules', 'openclaw'),
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const npmRoot = tryExec('npm', ['root', '-g']);
|
|
107
|
+
if (npmRoot) directCandidates.push(path.join(npmRoot, 'openclaw'));
|
|
108
|
+
|
|
109
|
+
const nodePathEntries = (process.env.NODE_PATH || '')
|
|
110
|
+
.split(path.delimiter)
|
|
111
|
+
.map((entry) => entry.trim())
|
|
112
|
+
.filter(Boolean);
|
|
113
|
+
for (const entry of nodePathEntries) {
|
|
114
|
+
directCandidates.push(path.join(entry, 'openclaw'));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const openclawBin = tryExec('which', ['openclaw']);
|
|
118
|
+
if (openclawBin) {
|
|
119
|
+
directCandidates.push(openclawBin);
|
|
120
|
+
directCandidates.push(path.dirname(openclawBin));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const packageRoots = unique(
|
|
124
|
+
directCandidates
|
|
125
|
+
.map((candidate) => findOpenClawPackageRoot(candidate))
|
|
126
|
+
.filter(Boolean),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return packageRoots.filter((candidate) => {
|
|
130
|
+
const pkgJson = path.join(candidate, 'package.json');
|
|
131
|
+
return fs.existsSync(pkgJson) && readOpenClawPackageName(pkgJson) === 'openclaw';
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const canResolveSdkCore = () => {
|
|
136
|
+
try {
|
|
137
|
+
pluginRequire.resolve(sdkCoreSpecifier);
|
|
138
|
+
return true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const ensurePluginNodeModulesLink = (targetRoot: string) => {
|
|
145
|
+
const nodeModulesDir = path.join(pluginDir, 'node_modules');
|
|
146
|
+
const linkPath = path.join(nodeModulesDir, 'openclaw');
|
|
147
|
+
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const stat = fs.lstatSync(linkPath);
|
|
151
|
+
if (stat.isSymbolicLink()) {
|
|
152
|
+
const existingTarget = fs.realpathSync(linkPath);
|
|
153
|
+
const normalizedExisting = path.normalize(existingTarget);
|
|
154
|
+
const normalizedTarget = path.normalize(fs.realpathSync(targetRoot));
|
|
155
|
+
if (normalizedExisting === normalizedTarget) return;
|
|
156
|
+
fs.unlinkSync(linkPath);
|
|
157
|
+
} else {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// missing link is fine
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
fs.symlinkSync(targetRoot, linkPath, linkType as fs.symlink.Type);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const ensureOpenClawSdkResolution = () => {
|
|
168
|
+
if (canResolveSdkCore()) return;
|
|
169
|
+
|
|
170
|
+
let lastError = '';
|
|
171
|
+
const candidates = collectOpenClawCandidates();
|
|
172
|
+
for (const candidate of candidates) {
|
|
173
|
+
try {
|
|
174
|
+
ensurePluginNodeModulesLink(candidate);
|
|
175
|
+
if (canResolveSdkCore()) return;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
lastError = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const suffix = candidates.length
|
|
182
|
+
? ` Tried candidates: ${candidates.join(', ')}.`
|
|
183
|
+
: ' No openclaw package root candidates were found from npm root, NODE_PATH, common global paths, or the openclaw binary path.';
|
|
184
|
+
const extra = lastError ? ` Last repair error: ${lastError}.` : '';
|
|
185
|
+
throw new Error(
|
|
186
|
+
`bncr failed to resolve ${sdkCoreSpecifier} from ${pluginDir}.${suffix}${extra} ` +
|
|
187
|
+
`You can repair manually with: mkdir -p ${path.join(pluginDir, 'node_modules')} && ln -s "$(npm root -g)/openclaw" ${path.join(pluginDir, 'node_modules', 'openclaw')}`,
|
|
188
|
+
);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const loadRuntimeSync = (): LoadedRuntime => {
|
|
192
|
+
if (runtime) return runtime;
|
|
193
|
+
ensureOpenClawSdkResolution();
|
|
194
|
+
try {
|
|
195
|
+
const mod = pluginRequire('./src/channel.ts') as ChannelModule;
|
|
196
|
+
runtime = {
|
|
197
|
+
createBncrBridge: mod.createBncrBridge,
|
|
198
|
+
createBncrChannelPlugin: mod.createBncrChannelPlugin,
|
|
199
|
+
};
|
|
200
|
+
return runtime;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
203
|
+
throw new Error(`bncr failed to load channel runtime after dependency bootstrap: ${detail}`);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const getIdentityId = (obj: object, prefix: string) => {
|
|
208
|
+
const existing = identityIds.get(obj);
|
|
209
|
+
if (existing) return existing;
|
|
210
|
+
const next = `${prefix}_${++identitySeq}`;
|
|
211
|
+
identityIds.set(obj, next);
|
|
212
|
+
return next;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const getRegistryFingerprint = (api: OpenClawPluginApi) => {
|
|
216
|
+
const serviceId = getIdentityId(api.registerService as object, 'svc');
|
|
217
|
+
const channelId = getIdentityId(api.registerChannel as object, 'chn');
|
|
218
|
+
const methodId = getIdentityId(api.registerGatewayMethod as object, 'mth');
|
|
219
|
+
return `${serviceId}:${channelId}:${methodId}`;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const getRegisterMeta = (api: OpenClawPluginApi): RegisterMeta => {
|
|
223
|
+
const host = api as OpenClawPluginApiWithMeta;
|
|
224
|
+
if (!host[BNCR_REGISTER_META]) {
|
|
225
|
+
host[BNCR_REGISTER_META] = { methods: new Set<string>() };
|
|
226
|
+
}
|
|
227
|
+
if (!host[BNCR_REGISTER_META]!.methods) {
|
|
228
|
+
host[BNCR_REGISTER_META]!.methods = new Set<string>();
|
|
229
|
+
}
|
|
230
|
+
if (!host[BNCR_REGISTER_META]!.apiInstanceId) {
|
|
231
|
+
host[BNCR_REGISTER_META]!.apiInstanceId = getIdentityId(api as object, 'api');
|
|
232
|
+
}
|
|
233
|
+
if (!host[BNCR_REGISTER_META]!.registryFingerprint) {
|
|
234
|
+
host[BNCR_REGISTER_META]!.registryFingerprint = getRegistryFingerprint(api);
|
|
235
|
+
}
|
|
236
|
+
return host[BNCR_REGISTER_META]!;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const ensureGatewayMethodRegistered = (
|
|
240
|
+
api: OpenClawPluginApi,
|
|
241
|
+
name: string,
|
|
242
|
+
handler: (opts: any) => any,
|
|
243
|
+
debugLog: (...args: any[]) => void,
|
|
244
|
+
) => {
|
|
245
|
+
const meta = getRegisterMeta(api);
|
|
246
|
+
if (meta.methods?.has(name)) {
|
|
247
|
+
debugLog(`bncr register method skip ${name} (already registered on this api)`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
api.registerGatewayMethod(name, handler);
|
|
251
|
+
meta.methods?.add(name);
|
|
252
|
+
debugLog(`bncr register method ok ${name}`);
|
|
253
|
+
};
|
|
9
254
|
|
|
10
255
|
const getBridgeSingleton = (api: OpenClawPluginApi) => {
|
|
256
|
+
const loaded = loadRuntimeSync();
|
|
11
257
|
const g = globalThis as typeof globalThis & { __bncrBridge?: BridgeSingleton };
|
|
12
|
-
|
|
13
|
-
|
|
258
|
+
let created = false;
|
|
259
|
+
if (!g.__bncrBridge) {
|
|
260
|
+
g.__bncrBridge = loaded.createBncrBridge(api);
|
|
261
|
+
created = true;
|
|
262
|
+
} else {
|
|
263
|
+
g.__bncrBridge.bindApi?.(api);
|
|
264
|
+
}
|
|
265
|
+
return { bridge: g.__bncrBridge, runtime: loaded, created };
|
|
14
266
|
};
|
|
15
267
|
|
|
16
268
|
const plugin = {
|
|
17
|
-
id:
|
|
18
|
-
name:
|
|
19
|
-
description:
|
|
269
|
+
id: 'bncr',
|
|
270
|
+
name: 'Bncr',
|
|
271
|
+
description: 'Bncr channel plugin',
|
|
20
272
|
configSchema: BncrConfigSchema,
|
|
21
273
|
register(api: OpenClawPluginApi) {
|
|
22
|
-
const
|
|
274
|
+
const meta = getRegisterMeta(api);
|
|
275
|
+
const { bridge, runtime, created } = getBridgeSingleton(api);
|
|
276
|
+
bridge.noteRegister?.({
|
|
277
|
+
source: '~/.openclaw/workspace/plugins/bncr/index.ts',
|
|
278
|
+
pluginVersion: '0.1.1',
|
|
279
|
+
apiRebound: !created,
|
|
280
|
+
apiInstanceId: meta.apiInstanceId,
|
|
281
|
+
registryFingerprint: meta.registryFingerprint,
|
|
282
|
+
});
|
|
23
283
|
const debugLog = (...args: any[]) => {
|
|
24
284
|
if (!bridge.isDebugEnabled?.()) return;
|
|
25
285
|
api.logger.info?.(...args);
|
|
26
286
|
};
|
|
27
287
|
|
|
28
|
-
debugLog(`bncr plugin register bridge=${(
|
|
288
|
+
debugLog(`bncr plugin register begin bridge=${bridge.getBridgeId?.() || 'unknown'} created=${created}`);
|
|
289
|
+
if (!created) debugLog('bncr bridge api rebound');
|
|
29
290
|
|
|
30
291
|
const resolveDebug = async () => {
|
|
31
292
|
try {
|
|
@@ -36,57 +297,40 @@ const plugin = {
|
|
|
36
297
|
}
|
|
37
298
|
};
|
|
38
299
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
300
|
+
if (!meta.service) {
|
|
301
|
+
api.registerService({
|
|
302
|
+
id: 'bncr-bridge-service',
|
|
303
|
+
start: async (ctx) => {
|
|
304
|
+
const debug = await resolveDebug();
|
|
305
|
+
await bridge.startService(ctx, debug);
|
|
306
|
+
},
|
|
307
|
+
stop: bridge.stopService,
|
|
308
|
+
});
|
|
309
|
+
meta.service = true;
|
|
310
|
+
debugLog('bncr register service ok');
|
|
311
|
+
} else {
|
|
312
|
+
debugLog('bncr register service skip (already registered on this api)');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!meta.channel) {
|
|
316
|
+
api.registerChannel({ plugin: runtime.createBncrChannelPlugin(bridge) });
|
|
317
|
+
meta.channel = true;
|
|
318
|
+
debugLog('bncr register channel ok');
|
|
319
|
+
} else {
|
|
320
|
+
debugLog('bncr register channel skip (already registered on this api)');
|
|
321
|
+
}
|
|
47
322
|
|
|
48
|
-
api.
|
|
49
|
-
|
|
50
|
-
api.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
);
|
|
54
|
-
api.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
"bncr.activity",
|
|
60
|
-
(opts: GatewayRequestHandlerOptions) => bridge.handleActivity(opts),
|
|
61
|
-
);
|
|
62
|
-
api.registerGatewayMethod(
|
|
63
|
-
"bncr.ack",
|
|
64
|
-
(opts: GatewayRequestHandlerOptions) => bridge.handleAck(opts),
|
|
65
|
-
);
|
|
66
|
-
api.registerGatewayMethod(
|
|
67
|
-
"bncr.diagnostics",
|
|
68
|
-
(opts: GatewayRequestHandlerOptions) => bridge.handleDiagnostics(opts),
|
|
69
|
-
);
|
|
70
|
-
api.registerGatewayMethod(
|
|
71
|
-
"bncr.file.init",
|
|
72
|
-
(opts: GatewayRequestHandlerOptions) => bridge.handleFileInit(opts),
|
|
73
|
-
);
|
|
74
|
-
api.registerGatewayMethod(
|
|
75
|
-
"bncr.file.chunk",
|
|
76
|
-
(opts: GatewayRequestHandlerOptions) => bridge.handleFileChunk(opts),
|
|
77
|
-
);
|
|
78
|
-
api.registerGatewayMethod(
|
|
79
|
-
"bncr.file.complete",
|
|
80
|
-
(opts: GatewayRequestHandlerOptions) => bridge.handleFileComplete(opts),
|
|
81
|
-
);
|
|
82
|
-
api.registerGatewayMethod(
|
|
83
|
-
"bncr.file.abort",
|
|
84
|
-
(opts: GatewayRequestHandlerOptions) => bridge.handleFileAbort(opts),
|
|
85
|
-
);
|
|
86
|
-
api.registerGatewayMethod(
|
|
87
|
-
"bncr.file.ack",
|
|
88
|
-
(opts: GatewayRequestHandlerOptions) => bridge.handleFileAck(opts),
|
|
89
|
-
);
|
|
323
|
+
ensureGatewayMethodRegistered(api, 'bncr.connect', (opts) => bridge.handleConnect(opts), debugLog);
|
|
324
|
+
ensureGatewayMethodRegistered(api, 'bncr.inbound', (opts) => bridge.handleInbound(opts), debugLog);
|
|
325
|
+
ensureGatewayMethodRegistered(api, 'bncr.activity', (opts) => bridge.handleActivity(opts), debugLog);
|
|
326
|
+
ensureGatewayMethodRegistered(api, 'bncr.ack', (opts) => bridge.handleAck(opts), debugLog);
|
|
327
|
+
ensureGatewayMethodRegistered(api, 'bncr.diagnostics', (opts) => bridge.handleDiagnostics(opts), debugLog);
|
|
328
|
+
ensureGatewayMethodRegistered(api, 'bncr.file.init', (opts) => bridge.handleFileInit(opts), debugLog);
|
|
329
|
+
ensureGatewayMethodRegistered(api, 'bncr.file.chunk', (opts) => bridge.handleFileChunk(opts), debugLog);
|
|
330
|
+
ensureGatewayMethodRegistered(api, 'bncr.file.complete', (opts) => bridge.handleFileComplete(opts), debugLog);
|
|
331
|
+
ensureGatewayMethodRegistered(api, 'bncr.file.abort', (opts) => bridge.handleFileAbort(opts), debugLog);
|
|
332
|
+
ensureGatewayMethodRegistered(api, 'bncr.file.ack', (opts) => bridge.handleFileAck(opts), debugLog);
|
|
333
|
+
debugLog('bncr plugin register done');
|
|
90
334
|
},
|
|
91
335
|
};
|
|
92
336
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmoxmo/bncr",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
27
|
"selfcheck": "node ./scripts/selfcheck.mjs",
|
|
28
|
-
"test": "node --import ./tests/register-ts-hooks.mjs --test ./tests/*.test.mjs"
|
|
28
|
+
"test": "node --import ./tests/register-ts-hooks.mjs --test ./tests/*.test.mjs",
|
|
29
|
+
"check-register-drift": "node ./scripts/check-register-drift.mjs"
|
|
29
30
|
},
|
|
30
31
|
"peerDependencies": {
|
|
31
32
|
"openclaw": ">=2026.3.22"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const readNumber = (value, fallback) => {
|
|
4
|
+
const n = Number(value);
|
|
5
|
+
return Number.isFinite(n) ? n : fallback;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const options = {
|
|
10
|
+
durationSec: 300,
|
|
11
|
+
intervalSec: 15,
|
|
12
|
+
accountId: 'Primary',
|
|
13
|
+
gatewayBin: 'openclaw',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
17
|
+
const arg = args[i];
|
|
18
|
+
if (arg === '--duration-sec') options.durationSec = readNumber(args[++i], options.durationSec);
|
|
19
|
+
else if (arg === '--interval-sec') options.intervalSec = readNumber(args[++i], options.intervalSec);
|
|
20
|
+
else if (arg === '--account-id') options.accountId = args[++i] || options.accountId;
|
|
21
|
+
else if (arg === '--gateway-bin') options.gatewayBin = args[++i] || options.gatewayBin;
|
|
22
|
+
else if (arg === '--help' || arg === '-h') {
|
|
23
|
+
console.log(`Usage: node ./scripts/check-register-drift.mjs [--duration-sec 300] [--interval-sec 15] [--account-id Primary] [--gateway-bin openclaw]\n\nSamples bncr.diagnostics over time and reports whether register counters drift after warmup.`);
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (options.durationSec <= 0) throw new Error('durationSec must be > 0');
|
|
29
|
+
if (options.intervalSec <= 0) throw new Error('intervalSec must be > 0');
|
|
30
|
+
|
|
31
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
32
|
+
|
|
33
|
+
const fetchDiagnostics = () => {
|
|
34
|
+
const raw = execFileSync(
|
|
35
|
+
options.gatewayBin,
|
|
36
|
+
['gateway', 'call', 'bncr.diagnostics', '--json', '--params', JSON.stringify({ accountId: options.accountId })],
|
|
37
|
+
{ encoding: 'utf8' },
|
|
38
|
+
);
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
const reg = parsed?.diagnostics?.register || {};
|
|
41
|
+
const summary = reg?.traceSummary || {};
|
|
42
|
+
return {
|
|
43
|
+
now: parsed?.now ?? Date.now(),
|
|
44
|
+
registerCount: reg?.registerCount ?? null,
|
|
45
|
+
apiGeneration: reg?.apiGeneration ?? null,
|
|
46
|
+
apiInstanceId: reg?.apiInstanceId ?? null,
|
|
47
|
+
registryFingerprint: reg?.registryFingerprint ?? null,
|
|
48
|
+
warmupRegisterCount: summary?.warmupRegisterCount ?? null,
|
|
49
|
+
postWarmupRegisterCount: summary?.postWarmupRegisterCount ?? null,
|
|
50
|
+
unexpectedRegisterAfterWarmup: summary?.unexpectedRegisterAfterWarmup ?? null,
|
|
51
|
+
lastUnexpectedRegisterAt: summary?.lastUnexpectedRegisterAt ?? null,
|
|
52
|
+
sourceBuckets: summary?.sourceBuckets ?? null,
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const startedAt = Date.now();
|
|
57
|
+
const samples = [];
|
|
58
|
+
const deadline = startedAt + options.durationSec * 1000;
|
|
59
|
+
|
|
60
|
+
while (true) {
|
|
61
|
+
const sample = fetchDiagnostics();
|
|
62
|
+
samples.push(sample);
|
|
63
|
+
const nextAt = Date.now() + options.intervalSec * 1000;
|
|
64
|
+
if (nextAt > deadline) break;
|
|
65
|
+
await sleep(Math.max(0, nextAt - Date.now()));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const first = samples[0] || {};
|
|
69
|
+
const last = samples[samples.length - 1] || {};
|
|
70
|
+
const deltaRegisterCount = (last.registerCount ?? 0) - (first.registerCount ?? 0);
|
|
71
|
+
const deltaApiGeneration = (last.apiGeneration ?? 0) - (first.apiGeneration ?? 0);
|
|
72
|
+
const deltaPostWarmupRegisterCount = (last.postWarmupRegisterCount ?? 0) - (first.postWarmupRegisterCount ?? 0);
|
|
73
|
+
const historicalWarmupExternalDrift = Boolean(first.unexpectedRegisterAfterWarmup);
|
|
74
|
+
const newWarmupExternalDriftDuringWindow = deltaPostWarmupRegisterCount > 0;
|
|
75
|
+
const newDriftDuringWindow = deltaRegisterCount > 0 || deltaApiGeneration > 0 || newWarmupExternalDriftDuringWindow;
|
|
76
|
+
const driftDetected = historicalWarmupExternalDrift || newDriftDuringWindow;
|
|
77
|
+
|
|
78
|
+
const result = {
|
|
79
|
+
ok: true,
|
|
80
|
+
accountId: options.accountId,
|
|
81
|
+
durationSec: options.durationSec,
|
|
82
|
+
intervalSec: options.intervalSec,
|
|
83
|
+
startedAt,
|
|
84
|
+
endedAt: Date.now(),
|
|
85
|
+
sampleCount: samples.length,
|
|
86
|
+
first,
|
|
87
|
+
last,
|
|
88
|
+
delta: {
|
|
89
|
+
registerCount: deltaRegisterCount,
|
|
90
|
+
apiGeneration: deltaApiGeneration,
|
|
91
|
+
postWarmupRegisterCount: deltaPostWarmupRegisterCount,
|
|
92
|
+
},
|
|
93
|
+
historicalWarmupExternalDrift,
|
|
94
|
+
newWarmupExternalDriftDuringWindow,
|
|
95
|
+
newDriftDuringWindow,
|
|
96
|
+
driftDetected,
|
|
97
|
+
conclusion: newDriftDuringWindow
|
|
98
|
+
? 'new register drift was observed during this sampling window'
|
|
99
|
+
: historicalWarmupExternalDrift
|
|
100
|
+
? 'no new drift during this window, but warmup-external drift had already happened before sampling began'
|
|
101
|
+
: 'register counters stayed stable during this window and no warmup-external drift was flagged',
|
|
102
|
+
samples,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
console.log(JSON.stringify(result, null, 2));
|
package/src/channel.ts
CHANGED
|
@@ -63,6 +63,7 @@ const FILE_CHUNK_RETRY = 3;
|
|
|
63
63
|
const FILE_ACK_TIMEOUT_MS = 30_000;
|
|
64
64
|
const FILE_TRANSFER_ACK_TTL_MS = 30_000;
|
|
65
65
|
const FILE_TRANSFER_KEEP_MS = 6 * 60 * 60 * 1000;
|
|
66
|
+
const REGISTER_WARMUP_WINDOW_MS = 30_000;
|
|
66
67
|
let BNCR_DEBUG_VERBOSE = false; // 全局调试日志开关(默认关闭)
|
|
67
68
|
|
|
68
69
|
type FileSendTransferState = {
|
|
@@ -130,6 +131,18 @@ type PersistedState = {
|
|
|
130
131
|
accountId: string;
|
|
131
132
|
updatedAt: number;
|
|
132
133
|
}>;
|
|
134
|
+
lastDriftSnapshot?: {
|
|
135
|
+
capturedAt: number;
|
|
136
|
+
registerCount: number | null;
|
|
137
|
+
apiGeneration: number | null;
|
|
138
|
+
postWarmupRegisterCount: number | null;
|
|
139
|
+
apiInstanceId: string | null;
|
|
140
|
+
registryFingerprint: string | null;
|
|
141
|
+
dominantBucket: string | null;
|
|
142
|
+
sourceBuckets: Record<string, number>;
|
|
143
|
+
traceWindowSize: number;
|
|
144
|
+
traceRecent: Array<Record<string, unknown>>;
|
|
145
|
+
} | null;
|
|
133
146
|
};
|
|
134
147
|
|
|
135
148
|
function now() {
|
|
@@ -205,6 +218,56 @@ class BncrBridgeRuntime {
|
|
|
205
218
|
private api: OpenClawPluginApi;
|
|
206
219
|
private statePath: string | null = null;
|
|
207
220
|
private bridgeId = `${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
|
|
221
|
+
private gatewayPid = process.pid;
|
|
222
|
+
private registerCount = 0;
|
|
223
|
+
private apiGeneration = 0;
|
|
224
|
+
private firstRegisterAt: number | null = null;
|
|
225
|
+
private lastRegisterAt: number | null = null;
|
|
226
|
+
private lastApiRebindAt: number | null = null;
|
|
227
|
+
private pluginSource: string | null = null;
|
|
228
|
+
private pluginVersion: string | null = null;
|
|
229
|
+
private connectionEpoch = 0;
|
|
230
|
+
private primaryLeaseId: string | null = null;
|
|
231
|
+
private acceptedConnections = 0;
|
|
232
|
+
private lastConnectAt: number | null = null;
|
|
233
|
+
private lastDisconnectAt: number | null = null;
|
|
234
|
+
private lastInboundAtGlobal: number | null = null;
|
|
235
|
+
private lastActivityAtGlobal: number | null = null;
|
|
236
|
+
private lastAckAtGlobal: number | null = null;
|
|
237
|
+
private recentConnections = new Map<string, {
|
|
238
|
+
epoch: number;
|
|
239
|
+
connectedAt: number;
|
|
240
|
+
lastActivityAt: number | null;
|
|
241
|
+
isPrimary: boolean;
|
|
242
|
+
}>();
|
|
243
|
+
private staleCounters = {
|
|
244
|
+
staleConnect: 0,
|
|
245
|
+
staleInbound: 0,
|
|
246
|
+
staleActivity: 0,
|
|
247
|
+
staleAck: 0,
|
|
248
|
+
staleFileInit: 0,
|
|
249
|
+
staleFileChunk: 0,
|
|
250
|
+
staleFileComplete: 0,
|
|
251
|
+
staleFileAbort: 0,
|
|
252
|
+
lastStaleAt: null as number | null,
|
|
253
|
+
};
|
|
254
|
+
private lastApiInstanceId: string | null = null;
|
|
255
|
+
private lastRegistryFingerprint: string | null = null;
|
|
256
|
+
private lastDriftSnapshot: PersistedState['lastDriftSnapshot'] = null;
|
|
257
|
+
private registerTraceRecent: Array<{
|
|
258
|
+
ts: number;
|
|
259
|
+
bridgeId: string;
|
|
260
|
+
gatewayPid: number;
|
|
261
|
+
registerCount: number;
|
|
262
|
+
apiGeneration: number;
|
|
263
|
+
apiRebound: boolean;
|
|
264
|
+
apiInstanceId: string | null;
|
|
265
|
+
registryFingerprint: string | null;
|
|
266
|
+
source: string | null;
|
|
267
|
+
pluginVersion: string | null;
|
|
268
|
+
stack: string;
|
|
269
|
+
stackBucket: string;
|
|
270
|
+
}> = [];
|
|
208
271
|
|
|
209
272
|
private connections = new Map<string, BncrConnection>(); // connectionKey -> connection
|
|
210
273
|
private activeConnectionByAccount = new Map<string, string>(); // accountId -> connectionKey
|
|
@@ -246,6 +309,266 @@ class BncrBridgeRuntime {
|
|
|
246
309
|
this.api = api;
|
|
247
310
|
}
|
|
248
311
|
|
|
312
|
+
bindApi(api: OpenClawPluginApi) {
|
|
313
|
+
this.api = api;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
getBridgeId() {
|
|
317
|
+
return this.bridgeId;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private classifyRegisterTrace(stack: string) {
|
|
321
|
+
if (stack.includes('prepareSecretsRuntimeSnapshot') || stack.includes('resolveRuntimeWebTools') || stack.includes('resolvePluginWebSearchProviders')) {
|
|
322
|
+
return 'runtime/webtools';
|
|
323
|
+
}
|
|
324
|
+
if (stack.includes('startGatewayServer') || stack.includes('loadGatewayPlugins')) {
|
|
325
|
+
return 'gateway/startup';
|
|
326
|
+
}
|
|
327
|
+
if (stack.includes('resolvePluginImplicitProviders')) {
|
|
328
|
+
return 'provider/discovery/implicit';
|
|
329
|
+
}
|
|
330
|
+
if (stack.includes('resolvePluginDiscoveryProviders')) {
|
|
331
|
+
return 'provider/discovery/discovery';
|
|
332
|
+
}
|
|
333
|
+
if (stack.includes('resolvePluginProviders')) {
|
|
334
|
+
return 'provider/discovery/providers';
|
|
335
|
+
}
|
|
336
|
+
return 'other';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private dominantRegisterBucket(sourceBuckets: Record<string, number>) {
|
|
340
|
+
let winner: string | null = null;
|
|
341
|
+
let winnerCount = -1;
|
|
342
|
+
for (const [bucket, count] of Object.entries(sourceBuckets)) {
|
|
343
|
+
if (count > winnerCount) {
|
|
344
|
+
winner = bucket;
|
|
345
|
+
winnerCount = count;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return winner;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private captureDriftSnapshot(summary: ReturnType<BncrBridgeRuntime['buildRegisterTraceSummary']>) {
|
|
352
|
+
this.lastDriftSnapshot = {
|
|
353
|
+
capturedAt: now(),
|
|
354
|
+
registerCount: this.registerCount,
|
|
355
|
+
apiGeneration: this.apiGeneration,
|
|
356
|
+
postWarmupRegisterCount: summary.postWarmupRegisterCount,
|
|
357
|
+
apiInstanceId: this.lastApiInstanceId,
|
|
358
|
+
registryFingerprint: this.lastRegistryFingerprint,
|
|
359
|
+
dominantBucket: summary.dominantBucket,
|
|
360
|
+
sourceBuckets: { ...summary.sourceBuckets },
|
|
361
|
+
traceWindowSize: this.registerTraceRecent.length,
|
|
362
|
+
traceRecent: this.registerTraceRecent.map((trace) => ({ ...trace })),
|
|
363
|
+
};
|
|
364
|
+
this.scheduleSave();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private buildRegisterTraceSummary() {
|
|
368
|
+
const buckets: Record<string, number> = {};
|
|
369
|
+
let warmupCount = 0;
|
|
370
|
+
let postWarmupCount = 0;
|
|
371
|
+
let unexpectedRegisterAfterWarmup = false;
|
|
372
|
+
let lastUnexpectedRegisterAt: number | null = null;
|
|
373
|
+
const baseline = this.firstRegisterAt;
|
|
374
|
+
|
|
375
|
+
for (const trace of this.registerTraceRecent) {
|
|
376
|
+
buckets[trace.stackBucket] = (buckets[trace.stackBucket] || 0) + 1;
|
|
377
|
+
const isWarmup = baseline != null && (trace.ts - baseline) <= REGISTER_WARMUP_WINDOW_MS;
|
|
378
|
+
if (isWarmup) {
|
|
379
|
+
warmupCount += 1;
|
|
380
|
+
} else {
|
|
381
|
+
postWarmupCount += 1;
|
|
382
|
+
unexpectedRegisterAfterWarmup = true;
|
|
383
|
+
lastUnexpectedRegisterAt = trace.ts;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const dominantBucket = this.dominantRegisterBucket(buckets);
|
|
388
|
+
const likelyRuntimeRegistryDrift = postWarmupCount > 0;
|
|
389
|
+
const likelyStartupFanoutOnly = warmupCount > 0 && postWarmupCount === 0;
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
startupWindowMs: REGISTER_WARMUP_WINDOW_MS,
|
|
393
|
+
traceWindowSize: this.registerTraceRecent.length,
|
|
394
|
+
sourceBuckets: buckets,
|
|
395
|
+
dominantBucket,
|
|
396
|
+
warmupRegisterCount: warmupCount,
|
|
397
|
+
postWarmupRegisterCount: postWarmupCount,
|
|
398
|
+
unexpectedRegisterAfterWarmup,
|
|
399
|
+
lastUnexpectedRegisterAt,
|
|
400
|
+
likelyRuntimeRegistryDrift,
|
|
401
|
+
likelyStartupFanoutOnly,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
noteRegister(meta: {
|
|
406
|
+
source?: string;
|
|
407
|
+
pluginVersion?: string;
|
|
408
|
+
apiRebound?: boolean;
|
|
409
|
+
apiInstanceId?: string;
|
|
410
|
+
registryFingerprint?: string;
|
|
411
|
+
}) {
|
|
412
|
+
const ts = now();
|
|
413
|
+
this.registerCount += 1;
|
|
414
|
+
if (this.firstRegisterAt == null) this.firstRegisterAt = ts;
|
|
415
|
+
this.lastRegisterAt = ts;
|
|
416
|
+
if (meta.apiRebound) {
|
|
417
|
+
this.apiGeneration += 1;
|
|
418
|
+
this.lastApiRebindAt = ts;
|
|
419
|
+
} else if (this.registerCount === 1 && this.apiGeneration === 0) {
|
|
420
|
+
this.apiGeneration = 1;
|
|
421
|
+
}
|
|
422
|
+
if (meta.source) this.pluginSource = meta.source;
|
|
423
|
+
if (meta.pluginVersion) this.pluginVersion = meta.pluginVersion;
|
|
424
|
+
if (meta.apiInstanceId) this.lastApiInstanceId = meta.apiInstanceId;
|
|
425
|
+
if (meta.registryFingerprint) this.lastRegistryFingerprint = meta.registryFingerprint;
|
|
426
|
+
|
|
427
|
+
const stack = String(new Error().stack || '')
|
|
428
|
+
.split('\n')
|
|
429
|
+
.slice(2, 7)
|
|
430
|
+
.map((line) => line.trim())
|
|
431
|
+
.filter(Boolean)
|
|
432
|
+
.join(' <- ');
|
|
433
|
+
const stackBucket = this.classifyRegisterTrace(stack);
|
|
434
|
+
|
|
435
|
+
const trace = {
|
|
436
|
+
ts,
|
|
437
|
+
bridgeId: this.bridgeId,
|
|
438
|
+
gatewayPid: this.gatewayPid,
|
|
439
|
+
registerCount: this.registerCount,
|
|
440
|
+
apiGeneration: this.apiGeneration,
|
|
441
|
+
apiRebound: meta.apiRebound === true,
|
|
442
|
+
apiInstanceId: this.lastApiInstanceId,
|
|
443
|
+
registryFingerprint: this.lastRegistryFingerprint,
|
|
444
|
+
source: this.pluginSource,
|
|
445
|
+
pluginVersion: this.pluginVersion,
|
|
446
|
+
stack,
|
|
447
|
+
stackBucket,
|
|
448
|
+
};
|
|
449
|
+
this.registerTraceRecent.push(trace);
|
|
450
|
+
if (this.registerTraceRecent.length > 12) this.registerTraceRecent.splice(0, this.registerTraceRecent.length - 12);
|
|
451
|
+
|
|
452
|
+
const summary = this.buildRegisterTraceSummary();
|
|
453
|
+
if (summary.postWarmupRegisterCount > 0) this.captureDriftSnapshot(summary);
|
|
454
|
+
|
|
455
|
+
this.api.logger.info?.(
|
|
456
|
+
`[bncr-register-trace] ${JSON.stringify(trace)}`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private createLeaseId() {
|
|
461
|
+
return typeof crypto?.randomUUID === 'function'
|
|
462
|
+
? `lease_${crypto.randomUUID()}`
|
|
463
|
+
: `lease_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private acceptConnection() {
|
|
467
|
+
const ts = now();
|
|
468
|
+
const leaseId = this.createLeaseId();
|
|
469
|
+
const connectionEpoch = ++this.connectionEpoch;
|
|
470
|
+
this.primaryLeaseId = leaseId;
|
|
471
|
+
this.acceptedConnections += 1;
|
|
472
|
+
this.lastConnectAt = ts;
|
|
473
|
+
this.recentConnections.set(leaseId, {
|
|
474
|
+
epoch: connectionEpoch,
|
|
475
|
+
connectedAt: ts,
|
|
476
|
+
lastActivityAt: null,
|
|
477
|
+
isPrimary: true,
|
|
478
|
+
});
|
|
479
|
+
for (const [id, entry] of this.recentConnections.entries()) {
|
|
480
|
+
if (id !== leaseId) entry.isPrimary = false;
|
|
481
|
+
}
|
|
482
|
+
while (this.recentConnections.size > 8) {
|
|
483
|
+
const oldest = this.recentConnections.keys().next().value;
|
|
484
|
+
if (!oldest) break;
|
|
485
|
+
this.recentConnections.delete(oldest);
|
|
486
|
+
}
|
|
487
|
+
return { leaseId, connectionEpoch, acceptedAt: ts };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private observeLease(
|
|
491
|
+
kind: 'connect' | 'inbound' | 'activity' | 'ack' | 'file.init' | 'file.chunk' | 'file.complete' | 'file.abort',
|
|
492
|
+
params: { leaseId?: string; connectionEpoch?: number },
|
|
493
|
+
) {
|
|
494
|
+
const leaseId = typeof params.leaseId === 'string' ? params.leaseId.trim() : '';
|
|
495
|
+
const connectionEpoch = typeof params.connectionEpoch === 'number' ? params.connectionEpoch : undefined;
|
|
496
|
+
if (!leaseId && connectionEpoch == null) return { stale: false, reason: 'missing' as const };
|
|
497
|
+
const staleByLease = !!leaseId && this.primaryLeaseId != null && leaseId !== this.primaryLeaseId;
|
|
498
|
+
const staleByEpoch = connectionEpoch != null && this.connectionEpoch > 0 && connectionEpoch !== this.connectionEpoch;
|
|
499
|
+
const stale = staleByLease || staleByEpoch;
|
|
500
|
+
if (!stale) return { stale: false, reason: 'ok' as const };
|
|
501
|
+
this.staleCounters.lastStaleAt = now();
|
|
502
|
+
switch (kind) {
|
|
503
|
+
case 'connect': this.staleCounters.staleConnect += 1; break;
|
|
504
|
+
case 'inbound': this.staleCounters.staleInbound += 1; break;
|
|
505
|
+
case 'activity': this.staleCounters.staleActivity += 1; break;
|
|
506
|
+
case 'ack': this.staleCounters.staleAck += 1; break;
|
|
507
|
+
case 'file.init': this.staleCounters.staleFileInit += 1; break;
|
|
508
|
+
case 'file.chunk': this.staleCounters.staleFileChunk += 1; break;
|
|
509
|
+
case 'file.complete': this.staleCounters.staleFileComplete += 1; break;
|
|
510
|
+
case 'file.abort': this.staleCounters.staleFileAbort += 1; break;
|
|
511
|
+
}
|
|
512
|
+
this.api.logger.warn?.(
|
|
513
|
+
`[bncr] stale ${kind} observed lease=${leaseId || '-'} epoch=${connectionEpoch ?? '-'} currentLease=${this.primaryLeaseId || '-'} currentEpoch=${this.connectionEpoch}`,
|
|
514
|
+
);
|
|
515
|
+
return { stale: true, reason: 'mismatch' as const };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private buildExtendedDiagnostics(accountId: string) {
|
|
519
|
+
const diagnostics = this.buildIntegratedDiagnostics(accountId) as Record<string, any>;
|
|
520
|
+
return {
|
|
521
|
+
...diagnostics,
|
|
522
|
+
register: {
|
|
523
|
+
bridgeId: this.bridgeId,
|
|
524
|
+
gatewayPid: this.gatewayPid,
|
|
525
|
+
pluginVersion: this.pluginVersion,
|
|
526
|
+
source: this.pluginSource,
|
|
527
|
+
apiInstanceId: this.lastApiInstanceId,
|
|
528
|
+
registryFingerprint: this.lastRegistryFingerprint,
|
|
529
|
+
registerCount: this.registerCount,
|
|
530
|
+
firstRegisterAt: this.firstRegisterAt,
|
|
531
|
+
lastRegisterAt: this.lastRegisterAt,
|
|
532
|
+
lastApiRebindAt: this.lastApiRebindAt,
|
|
533
|
+
apiGeneration: this.apiGeneration,
|
|
534
|
+
traceRecent: this.registerTraceRecent.slice(),
|
|
535
|
+
traceSummary: this.buildRegisterTraceSummary(),
|
|
536
|
+
lastDriftSnapshot: this.lastDriftSnapshot,
|
|
537
|
+
},
|
|
538
|
+
connection: {
|
|
539
|
+
active: this.activeConnectionCount(accountId),
|
|
540
|
+
primaryLeaseId: this.primaryLeaseId,
|
|
541
|
+
primaryEpoch: this.connectionEpoch || null,
|
|
542
|
+
acceptedConnections: this.acceptedConnections,
|
|
543
|
+
lastConnectAt: this.lastConnectAt,
|
|
544
|
+
lastDisconnectAt: this.lastDisconnectAt,
|
|
545
|
+
lastActivityAt: this.lastActivityAtGlobal,
|
|
546
|
+
lastInboundAt: this.lastInboundAtGlobal,
|
|
547
|
+
lastAckAt: this.lastAckAtGlobal,
|
|
548
|
+
recent: Array.from(this.recentConnections.entries()).map(([leaseId, entry]) => ({
|
|
549
|
+
leaseId,
|
|
550
|
+
epoch: entry.epoch,
|
|
551
|
+
connectedAt: entry.connectedAt,
|
|
552
|
+
lastActivityAt: entry.lastActivityAt,
|
|
553
|
+
isPrimary: entry.isPrimary,
|
|
554
|
+
})),
|
|
555
|
+
},
|
|
556
|
+
protocol: {
|
|
557
|
+
bridgeVersion: BRIDGE_VERSION,
|
|
558
|
+
protocolVersion: 2,
|
|
559
|
+
minClientProtocol: 1,
|
|
560
|
+
features: {
|
|
561
|
+
leaseId: true,
|
|
562
|
+
connectionEpoch: true,
|
|
563
|
+
staleObserveOnly: true,
|
|
564
|
+
staleRejectAck: false,
|
|
565
|
+
staleRejectFile: false,
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
stale: { ...this.staleCounters },
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
249
572
|
isDebugEnabled(): boolean {
|
|
250
573
|
try {
|
|
251
574
|
const cfg = (this.api.runtime.config?.get?.() as any) || {};
|
|
@@ -500,6 +823,25 @@ class BncrBridgeRuntime {
|
|
|
500
823
|
this.lastOutboundByAccount.set(accountId, updatedAt);
|
|
501
824
|
}
|
|
502
825
|
|
|
826
|
+
this.lastDriftSnapshot = data.lastDriftSnapshot && typeof data.lastDriftSnapshot === 'object'
|
|
827
|
+
? {
|
|
828
|
+
capturedAt: Number((data.lastDriftSnapshot as any).capturedAt || 0),
|
|
829
|
+
registerCount: Number.isFinite(Number((data.lastDriftSnapshot as any).registerCount)) ? Number((data.lastDriftSnapshot as any).registerCount) : null,
|
|
830
|
+
apiGeneration: Number.isFinite(Number((data.lastDriftSnapshot as any).apiGeneration)) ? Number((data.lastDriftSnapshot as any).apiGeneration) : null,
|
|
831
|
+
postWarmupRegisterCount: Number.isFinite(Number((data.lastDriftSnapshot as any).postWarmupRegisterCount)) ? Number((data.lastDriftSnapshot as any).postWarmupRegisterCount) : null,
|
|
832
|
+
apiInstanceId: asString((data.lastDriftSnapshot as any).apiInstanceId || '').trim() || null,
|
|
833
|
+
registryFingerprint: asString((data.lastDriftSnapshot as any).registryFingerprint || '').trim() || null,
|
|
834
|
+
dominantBucket: asString((data.lastDriftSnapshot as any).dominantBucket || '').trim() || null,
|
|
835
|
+
sourceBuckets: ((data.lastDriftSnapshot as any).sourceBuckets && typeof (data.lastDriftSnapshot as any).sourceBuckets === 'object')
|
|
836
|
+
? { ...((data.lastDriftSnapshot as any).sourceBuckets as Record<string, number>) }
|
|
837
|
+
: {},
|
|
838
|
+
traceWindowSize: Number((data.lastDriftSnapshot as any).traceWindowSize || 0),
|
|
839
|
+
traceRecent: Array.isArray((data.lastDriftSnapshot as any).traceRecent)
|
|
840
|
+
? [ ...((data.lastDriftSnapshot as any).traceRecent as Array<Record<string, unknown>>) ]
|
|
841
|
+
: [],
|
|
842
|
+
}
|
|
843
|
+
: null;
|
|
844
|
+
|
|
503
845
|
// 兼容旧状态文件:若尚未持久化 lastSession*/lastActivity*,从 sessionRoutes 回填。
|
|
504
846
|
if (this.lastSessionByAccount.size === 0 && this.sessionRoutes.size > 0) {
|
|
505
847
|
for (const [sessionKey, info] of this.sessionRoutes.entries()) {
|
|
@@ -560,6 +902,20 @@ class BncrBridgeRuntime {
|
|
|
560
902
|
accountId,
|
|
561
903
|
updatedAt,
|
|
562
904
|
})),
|
|
905
|
+
lastDriftSnapshot: this.lastDriftSnapshot
|
|
906
|
+
? {
|
|
907
|
+
capturedAt: this.lastDriftSnapshot.capturedAt,
|
|
908
|
+
registerCount: this.lastDriftSnapshot.registerCount,
|
|
909
|
+
apiGeneration: this.lastDriftSnapshot.apiGeneration,
|
|
910
|
+
postWarmupRegisterCount: this.lastDriftSnapshot.postWarmupRegisterCount,
|
|
911
|
+
apiInstanceId: this.lastDriftSnapshot.apiInstanceId,
|
|
912
|
+
registryFingerprint: this.lastDriftSnapshot.registryFingerprint,
|
|
913
|
+
dominantBucket: this.lastDriftSnapshot.dominantBucket,
|
|
914
|
+
sourceBuckets: { ...this.lastDriftSnapshot.sourceBuckets },
|
|
915
|
+
traceWindowSize: this.lastDriftSnapshot.traceWindowSize,
|
|
916
|
+
traceRecent: this.lastDriftSnapshot.traceRecent.map((trace) => ({ ...trace })),
|
|
917
|
+
}
|
|
918
|
+
: null,
|
|
563
919
|
};
|
|
564
920
|
|
|
565
921
|
await writeJsonFileAtomically(this.statePath, data);
|
|
@@ -1718,6 +2074,7 @@ class BncrBridgeRuntime {
|
|
|
1718
2074
|
this.markSeen(accountId, connId, clientId);
|
|
1719
2075
|
this.markActivity(accountId);
|
|
1720
2076
|
this.incrementCounter(this.connectEventsByAccount, accountId);
|
|
2077
|
+
const lease = this.acceptConnection();
|
|
1721
2078
|
|
|
1722
2079
|
respond(true, {
|
|
1723
2080
|
channel: CHANNEL_ID,
|
|
@@ -1729,7 +2086,13 @@ class BncrBridgeRuntime {
|
|
|
1729
2086
|
activeConnections: this.activeConnectionCount(accountId),
|
|
1730
2087
|
pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
|
|
1731
2088
|
deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
|
|
1732
|
-
diagnostics: this.
|
|
2089
|
+
diagnostics: this.buildExtendedDiagnostics(accountId),
|
|
2090
|
+
leaseId: lease.leaseId,
|
|
2091
|
+
connectionEpoch: lease.connectionEpoch,
|
|
2092
|
+
protocolVersion: 2,
|
|
2093
|
+
acceptedAt: lease.acceptedAt,
|
|
2094
|
+
serverPid: this.gatewayPid,
|
|
2095
|
+
bridgeId: this.bridgeId,
|
|
1733
2096
|
now: now(),
|
|
1734
2097
|
});
|
|
1735
2098
|
|
|
@@ -1743,6 +2106,8 @@ class BncrBridgeRuntime {
|
|
|
1743
2106
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
1744
2107
|
this.rememberGatewayContext(context);
|
|
1745
2108
|
this.markSeen(accountId, connId, clientId);
|
|
2109
|
+
this.observeLease('ack', params ?? {});
|
|
2110
|
+
this.lastAckAtGlobal = now();
|
|
1746
2111
|
this.incrementCounter(this.ackEventsByAccount, accountId);
|
|
1747
2112
|
|
|
1748
2113
|
const messageId = asString(params?.messageId || '').trim();
|
|
@@ -1802,6 +2167,8 @@ class BncrBridgeRuntime {
|
|
|
1802
2167
|
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
1803
2168
|
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
1804
2169
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2170
|
+
this.observeLease('activity', params ?? {});
|
|
2171
|
+
this.lastActivityAtGlobal = now();
|
|
1805
2172
|
if (BNCR_DEBUG_VERBOSE) {
|
|
1806
2173
|
this.api.logger.info?.(
|
|
1807
2174
|
`[bncr-activity] ${JSON.stringify({
|
|
@@ -1834,7 +2201,7 @@ class BncrBridgeRuntime {
|
|
|
1834
2201
|
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
1835
2202
|
const cfg = await this.api.runtime.config.loadConfig();
|
|
1836
2203
|
const runtime = this.getAccountRuntimeSnapshot(accountId);
|
|
1837
|
-
const diagnostics = this.
|
|
2204
|
+
const diagnostics = this.buildExtendedDiagnostics(accountId);
|
|
1838
2205
|
const permissions = buildBncrPermissionSummary(cfg ?? {});
|
|
1839
2206
|
const probe = probeBncrAccount({
|
|
1840
2207
|
accountId,
|
|
@@ -1869,6 +2236,7 @@ class BncrBridgeRuntime {
|
|
|
1869
2236
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
1870
2237
|
this.rememberGatewayContext(context);
|
|
1871
2238
|
this.markSeen(accountId, connId, clientId);
|
|
2239
|
+
this.observeLease('file.init', params ?? {});
|
|
1872
2240
|
this.markActivity(accountId);
|
|
1873
2241
|
|
|
1874
2242
|
const transferId = asString(params?.transferId || '').trim();
|
|
@@ -1938,6 +2306,7 @@ class BncrBridgeRuntime {
|
|
|
1938
2306
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
1939
2307
|
this.rememberGatewayContext(context);
|
|
1940
2308
|
this.markSeen(accountId, connId, clientId);
|
|
2309
|
+
this.observeLease('file.chunk', params ?? {});
|
|
1941
2310
|
this.markActivity(accountId);
|
|
1942
2311
|
|
|
1943
2312
|
const transferId = asString(params?.transferId || '').trim();
|
|
@@ -1991,6 +2360,7 @@ class BncrBridgeRuntime {
|
|
|
1991
2360
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
1992
2361
|
this.rememberGatewayContext(context);
|
|
1993
2362
|
this.markSeen(accountId, connId, clientId);
|
|
2363
|
+
this.observeLease('file.complete', params ?? {});
|
|
1994
2364
|
this.markActivity(accountId);
|
|
1995
2365
|
|
|
1996
2366
|
const transferId = asString(params?.transferId || '').trim();
|
|
@@ -2054,6 +2424,7 @@ class BncrBridgeRuntime {
|
|
|
2054
2424
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2055
2425
|
this.rememberGatewayContext(context);
|
|
2056
2426
|
this.markSeen(accountId, connId, clientId);
|
|
2427
|
+
this.observeLease('file.abort', params ?? {});
|
|
2057
2428
|
this.markActivity(accountId);
|
|
2058
2429
|
|
|
2059
2430
|
const transferId = asString(params?.transferId || '').trim();
|
|
@@ -2149,7 +2520,9 @@ class BncrBridgeRuntime {
|
|
|
2149
2520
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2150
2521
|
this.rememberGatewayContext(context);
|
|
2151
2522
|
this.markSeen(accountId, connId, clientId);
|
|
2523
|
+
this.observeLease('inbound', params ?? {});
|
|
2152
2524
|
this.markActivity(accountId);
|
|
2525
|
+
this.lastInboundAtGlobal = now();
|
|
2153
2526
|
this.incrementCounter(this.inboundEventsByAccount, accountId);
|
|
2154
2527
|
|
|
2155
2528
|
if (!platform || (!userId && !groupId)) {
|