@coclaw/openclaw-coclaw 0.11.6 → 0.12.1
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 +1 -1
- package/index.js +8 -8
- package/package.json +2 -2
- package/src/api.js +2 -2
- package/src/common/{bot-binding.js → claw-binding.js} +17 -17
- package/src/common/errors.js +1 -1
- package/src/common/messages.js +6 -6
- package/src/config.js +7 -2
- package/src/file-manager/handler.js +144 -50
- package/src/realtime-bridge.js +11 -11
- package/vendor/ndc-prebuilds/darwin-arm64/node_datachannel.node +0 -0
- package/vendor/ndc-prebuilds/darwin-x64/node_datachannel.node +0 -0
- package/vendor/ndc-prebuilds/linux-arm64/node_datachannel.node +0 -0
- package/vendor/ndc-prebuilds/linux-x64/node_datachannel.node +0 -0
- package/vendor/ndc-prebuilds/win32-x64/node_datachannel.node +0 -0
package/README.md
CHANGED
package/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { bindClaw, unbindClaw, enrollClaw, waitForClaimAndSave } from './src/common/claw-binding.js';
|
|
2
2
|
import { registerCoclawCli } from './src/cli-registrar.js';
|
|
3
3
|
import { resolveErrorMessage } from './src/common/errors.js';
|
|
4
4
|
import { notBound, bindOk, unbindOk, claimCodeCreated } from './src/common/messages.js';
|
|
@@ -116,7 +116,7 @@ const plugin = {
|
|
|
116
116
|
await stopRealtimeBridge();
|
|
117
117
|
let result;
|
|
118
118
|
try {
|
|
119
|
-
result = await
|
|
119
|
+
result = await bindClaw({
|
|
120
120
|
code,
|
|
121
121
|
serverUrl: serverUrl ?? api.pluginConfig?.serverUrl,
|
|
122
122
|
});
|
|
@@ -133,7 +133,7 @@ const plugin = {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
async function doUnbind({ serverUrl }) {
|
|
136
|
-
const result = await
|
|
136
|
+
const result = await unbindClaw({
|
|
137
137
|
serverUrl: serverUrl ?? api.pluginConfig?.serverUrl,
|
|
138
138
|
});
|
|
139
139
|
await stopRealtimeBridge();
|
|
@@ -153,9 +153,9 @@ const plugin = {
|
|
|
153
153
|
});
|
|
154
154
|
respond(true, {
|
|
155
155
|
status: {
|
|
156
|
-
|
|
156
|
+
clawId: result.clawId,
|
|
157
157
|
rebound: result.rebound,
|
|
158
|
-
|
|
158
|
+
previousClawId: result.previousClawId,
|
|
159
159
|
},
|
|
160
160
|
});
|
|
161
161
|
}
|
|
@@ -167,7 +167,7 @@ const plugin = {
|
|
|
167
167
|
api.registerGatewayMethod('coclaw.unbind', async ({ params, respond }) => {
|
|
168
168
|
try {
|
|
169
169
|
const result = await doUnbind({ serverUrl: params?.serverUrl });
|
|
170
|
-
respond(true, { status: {
|
|
170
|
+
respond(true, { status: { clawId: result.clawId } });
|
|
171
171
|
}
|
|
172
172
|
catch (err) {
|
|
173
173
|
respondError(respond, err);
|
|
@@ -187,7 +187,7 @@ const plugin = {
|
|
|
187
187
|
activeEnrollAbort = abortController;
|
|
188
188
|
|
|
189
189
|
const serverUrl = params?.serverUrl ?? api.pluginConfig?.serverUrl;
|
|
190
|
-
const result = await
|
|
190
|
+
const result = await enrollClaw({ serverUrl });
|
|
191
191
|
|
|
192
192
|
const rawMinutes = Math.round(
|
|
193
193
|
(new Date(result.expiresAt).getTime() - Date.now()) / 60_000,
|
|
@@ -552,7 +552,7 @@ const plugin = {
|
|
|
552
552
|
activeEnrollAbort = abortController;
|
|
553
553
|
|
|
554
554
|
const serverUrl = options.server ?? api.pluginConfig?.serverUrl;
|
|
555
|
-
const result = await
|
|
555
|
+
const result = await enrollClaw({ serverUrl });
|
|
556
556
|
const rawMinutes = Math.round(
|
|
557
557
|
(new Date(result.expiresAt).getTime() - Date.now()) / 60_000,
|
|
558
558
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coclaw/openclaw-coclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "OpenClaw CoClaw channel plugin for remote chat",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"release:versions": "npm view @coclaw/openclaw-coclaw versions --json --registry=https://registry.npmjs.org/ && npm view @coclaw/openclaw-coclaw versions --json"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"node-datachannel": "0.32.
|
|
62
|
+
"node-datachannel": "0.32.2",
|
|
63
63
|
"werift": "^0.19.0"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
package/src/api.js
CHANGED
|
@@ -31,7 +31,7 @@ const CLAIM_CODE_TIMEOUT = 15_000;
|
|
|
31
31
|
const CLAIM_WAIT_TIMEOUT = 30_000;
|
|
32
32
|
|
|
33
33
|
export async function bindWithServer({ baseUrl, code, name }) {
|
|
34
|
-
return requestJson(baseUrl, '/api/v1/
|
|
34
|
+
return requestJson(baseUrl, '/api/v1/claws/bind', {
|
|
35
35
|
method: 'POST',
|
|
36
36
|
headers: { 'content-type': 'application/json' },
|
|
37
37
|
body: { code, name },
|
|
@@ -40,7 +40,7 @@ export async function bindWithServer({ baseUrl, code, name }) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export async function unbindWithServer({ baseUrl, token, timeout = UNBIND_TIMEOUT }) {
|
|
43
|
-
return requestJson(baseUrl, '/api/v1/
|
|
43
|
+
return requestJson(baseUrl, '/api/v1/claws/unbind', {
|
|
44
44
|
method: 'POST',
|
|
45
45
|
headers: {
|
|
46
46
|
Authorization: `Bearer ${token}`,
|
|
@@ -8,14 +8,14 @@ function resolveServerUrl(serverUrl) {
|
|
|
8
8
|
return serverUrl ?? process.env.COCLAW_SERVER_URL ?? DEFAULT_SERVER_URL;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
// 这些 HTTP 状态码表示
|
|
11
|
+
// 这些 HTTP 状态码表示 claw 在 server 端已不存在,视为解绑成功
|
|
12
12
|
const ALREADY_UNBOUND_STATUSES = new Set([401, 404, 410]);
|
|
13
13
|
|
|
14
14
|
function isAlreadyUnbound(err) {
|
|
15
15
|
return ALREADY_UNBOUND_STATUSES.has(err?.response?.status);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export async function
|
|
18
|
+
export async function bindClaw({ code, serverUrl }, deps = {}) {
|
|
19
19
|
const {
|
|
20
20
|
readCfg = readConfig,
|
|
21
21
|
clearCfg = clearConfig,
|
|
@@ -30,10 +30,10 @@ export async function bindBot({ code, serverUrl }, deps = {}) {
|
|
|
30
30
|
|
|
31
31
|
const config = await readCfg();
|
|
32
32
|
|
|
33
|
-
// 已绑定时必须先解绑旧
|
|
34
|
-
let
|
|
33
|
+
// 已绑定时必须先解绑旧 claw,避免产生孤儿记录
|
|
34
|
+
let previousClawId;
|
|
35
35
|
if (config?.token) {
|
|
36
|
-
|
|
36
|
+
previousClawId = config.clawId || 'unknown';
|
|
37
37
|
const oldBaseUrl = config.serverUrl;
|
|
38
38
|
if (oldBaseUrl) {
|
|
39
39
|
try {
|
|
@@ -41,7 +41,7 @@ export async function bindBot({ code, serverUrl }, deps = {}) {
|
|
|
41
41
|
} catch (err) {
|
|
42
42
|
if (!isAlreadyUnbound(err)) {
|
|
43
43
|
const rebindErr = new Error(
|
|
44
|
-
`Failed to unbind previous
|
|
44
|
+
`Failed to unbind previous claw (${previousClawId}): ${err.message}. ` +
|
|
45
45
|
'Unbind manually first, then retry.',
|
|
46
46
|
);
|
|
47
47
|
rebindErr.code = 'UNBIND_FAILED';
|
|
@@ -59,25 +59,25 @@ export async function bindBot({ code, serverUrl }, deps = {}) {
|
|
|
59
59
|
code,
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
if (!data?.
|
|
62
|
+
if (!data?.clawId || !data?.token) {
|
|
63
63
|
throw new Error('invalid bind response');
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
await writeCfg({
|
|
67
67
|
serverUrl: baseUrl,
|
|
68
|
-
|
|
68
|
+
clawId: data.clawId,
|
|
69
69
|
token: data.token,
|
|
70
70
|
boundAt: new Date().toISOString(),
|
|
71
71
|
});
|
|
72
72
|
|
|
73
73
|
return {
|
|
74
|
-
|
|
74
|
+
clawId: data.clawId,
|
|
75
75
|
rebound: Boolean(data.rebound),
|
|
76
|
-
|
|
76
|
+
previousClawId,
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
export async function
|
|
80
|
+
export async function enrollClaw({ serverUrl }, deps = {}) {
|
|
81
81
|
const { createClaimCode = createClaimCodeOnServer, readCfg = readConfig } = deps;
|
|
82
82
|
|
|
83
83
|
const config = await readCfg();
|
|
@@ -129,14 +129,14 @@ export async function waitForClaimAndSave({ serverUrl, code, waitToken, signal }
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
// 已认领
|
|
132
|
-
if (data?.
|
|
132
|
+
if (data?.clawId && data?.token) {
|
|
133
133
|
await writeCfg({
|
|
134
134
|
serverUrl: baseUrl,
|
|
135
|
-
|
|
135
|
+
clawId: data.clawId,
|
|
136
136
|
token: data.token,
|
|
137
137
|
boundAt: new Date().toISOString(),
|
|
138
138
|
});
|
|
139
|
-
return {
|
|
139
|
+
return { clawId: data.clawId };
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
// PENDING — 延迟后继续轮询
|
|
@@ -150,7 +150,7 @@ export async function waitForClaimAndSave({ serverUrl, code, waitToken, signal }
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
export async function
|
|
153
|
+
export async function unbindClaw({ serverUrl }, deps = {}) {
|
|
154
154
|
const {
|
|
155
155
|
readCfg = readConfig,
|
|
156
156
|
clearCfg = clearConfig,
|
|
@@ -170,7 +170,7 @@ export async function unbindBot({ serverUrl }, deps = {}) {
|
|
|
170
170
|
try {
|
|
171
171
|
await unbindServer({ baseUrl, token: config.token });
|
|
172
172
|
} catch (err) {
|
|
173
|
-
//
|
|
173
|
+
// claw 在 server 已不存在 — 视为解绑成功,继续清理本地
|
|
174
174
|
if (!isAlreadyUnbound(err)) {
|
|
175
175
|
throw err;
|
|
176
176
|
}
|
|
@@ -179,5 +179,5 @@ export async function unbindBot({ serverUrl }, deps = {}) {
|
|
|
179
179
|
|
|
180
180
|
await clearCfg();
|
|
181
181
|
|
|
182
|
-
return {
|
|
182
|
+
return { clawId: config.clawId };
|
|
183
183
|
}
|
package/src/common/errors.js
CHANGED
|
@@ -3,7 +3,7 @@ const ERROR_TEXT_MAP = {
|
|
|
3
3
|
BINDING_CODE_INVALID: 'Binding code is invalid',
|
|
4
4
|
BINDING_CODE_EXPIRED: 'Binding code has expired, please get a new one',
|
|
5
5
|
BINDING_CODE_EXHAUSTED: 'Server cannot generate binding code right now, please try again later',
|
|
6
|
-
|
|
6
|
+
CLAW_BLOCKED: 'Claw is blocked, please contact the admin',
|
|
7
7
|
UNAUTHORIZED: 'Auth failed, please check token or re-bind',
|
|
8
8
|
INTERNAL_SERVER_ERROR: 'Server error, please try again later',
|
|
9
9
|
};
|
package/src/common/messages.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// bind/unbind CLI 及 command 的用户提示文案(统一出口)
|
|
2
2
|
|
|
3
|
-
export function bindOk({
|
|
3
|
+
export function bindOk({ clawId, rebound, previousClawId }) {
|
|
4
4
|
const action = rebound ? 're-bound' : 'bound';
|
|
5
|
-
const prev =
|
|
6
|
-
? ` (previous Claw ${
|
|
5
|
+
const prev = previousClawId
|
|
6
|
+
? ` (previous Claw ${previousClawId} was auto-unbound)`
|
|
7
7
|
: '';
|
|
8
|
-
return `OK. Claw (${
|
|
8
|
+
return `OK. Claw (${clawId}) ${action} to CoClaw.${prev}`;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export function unbindOk({
|
|
12
|
-
const id =
|
|
11
|
+
export function unbindOk({ clawId }) {
|
|
12
|
+
const id = clawId ?? 'unknown';
|
|
13
13
|
return `OK. Claw (${id}) unbound from CoClaw.`;
|
|
14
14
|
}
|
|
15
15
|
|
package/src/config.js
CHANGED
|
@@ -58,7 +58,12 @@ const bindingsMutex = createMutex();
|
|
|
58
58
|
export async function readConfig(accountId = DEFAULT_ACCOUNT_ID) {
|
|
59
59
|
const bindingsPath = getBindingsPath();
|
|
60
60
|
const bindings = await readJson(bindingsPath);
|
|
61
|
-
|
|
61
|
+
const record = toRecord(bindings[accountId]);
|
|
62
|
+
// 向后兼容:旧 bindings.json 使用 botId,映射到 clawId
|
|
63
|
+
if (record.botId && !record.clawId) {
|
|
64
|
+
record.clawId = record.botId;
|
|
65
|
+
}
|
|
66
|
+
return record;
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
export async function writeConfig(nextConfig, accountId = DEFAULT_ACCOUNT_ID) {
|
|
@@ -69,7 +74,7 @@ export async function writeConfig(nextConfig, accountId = DEFAULT_ACCOUNT_ID) {
|
|
|
69
74
|
|
|
70
75
|
const next = { ...current };
|
|
71
76
|
if (nextConfig.serverUrl !== undefined) next.serverUrl = nextConfig.serverUrl;
|
|
72
|
-
if (nextConfig.
|
|
77
|
+
if (nextConfig.clawId !== undefined) next.clawId = nextConfig.clawId;
|
|
73
78
|
if (nextConfig.token !== undefined) next.token = nextConfig.token;
|
|
74
79
|
if (nextConfig.boundAt !== undefined) next.boundAt = nextConfig.boundAt;
|
|
75
80
|
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import fsp from 'node:fs/promises';
|
|
3
3
|
import nodePath from 'node:path';
|
|
4
4
|
import { randomUUID, randomBytes } from 'node:crypto';
|
|
5
|
+
import { remoteLog } from '../remote-log.js';
|
|
5
6
|
|
|
6
7
|
// --- 常量 ---
|
|
7
8
|
|
|
@@ -312,8 +313,9 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
312
313
|
/**
|
|
313
314
|
* 处理 file:<transferId> DataChannel
|
|
314
315
|
* @param {object} dc - werift DataChannel
|
|
316
|
+
* @param {string} [connId] - 所属 PeerConnection 的连接 ID
|
|
315
317
|
*/
|
|
316
|
-
function handleFileChannel(dc) {
|
|
318
|
+
function handleFileChannel(dc, connId) {
|
|
317
319
|
let requestTimer = setTimeout(() => {
|
|
318
320
|
try {
|
|
319
321
|
dc.send(JSON.stringify({
|
|
@@ -353,12 +355,12 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
353
355
|
});
|
|
354
356
|
} else if (req.method === 'PUT') {
|
|
355
357
|
/* c8 ignore next 3 -- handlePut 内部已完整处理异常,此 catch 纯防御 */
|
|
356
|
-
handlePut(dc, req).catch((err) => {
|
|
358
|
+
handlePut(dc, req, connId).catch((err) => {
|
|
357
359
|
log.warn?.(`[coclaw/file] PUT error: ${err.message}`);
|
|
358
360
|
});
|
|
359
361
|
} else if (req.method === 'POST') {
|
|
360
362
|
/* c8 ignore next 3 -- handlePost 内部已完整处理异常,此 catch 纯防御 */
|
|
361
|
-
handlePost(dc, req).catch((err) => {
|
|
363
|
+
handlePost(dc, req, connId).catch((err) => {
|
|
362
364
|
log.warn?.(`[coclaw/file] POST error: ${err.message}`);
|
|
363
365
|
});
|
|
364
366
|
} else {
|
|
@@ -456,7 +458,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
456
458
|
});
|
|
457
459
|
}
|
|
458
460
|
|
|
459
|
-
async function handlePut(dc, req) {
|
|
461
|
+
async function handlePut(dc, req, connId) {
|
|
460
462
|
let workspaceDir, resolved;
|
|
461
463
|
try {
|
|
462
464
|
const agentId = req.agentId?.trim?.() || 'main';
|
|
@@ -466,10 +468,10 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
466
468
|
sendError(dc, err.code ?? 'INTERNAL_ERROR', err.message);
|
|
467
469
|
return;
|
|
468
470
|
}
|
|
469
|
-
await receiveUpload(dc, req, resolved);
|
|
471
|
+
await receiveUpload(dc, req, resolved, undefined, connId);
|
|
470
472
|
}
|
|
471
473
|
|
|
472
|
-
async function handlePost(dc, req) {
|
|
474
|
+
async function handlePost(dc, req, connId) {
|
|
473
475
|
let workspaceDir, dirResolved;
|
|
474
476
|
try {
|
|
475
477
|
const agentId = req.agentId?.trim?.() || 'main';
|
|
@@ -508,7 +510,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
508
510
|
const resolved = nodePath.join(dirResolved, uniqueName);
|
|
509
511
|
// 计算相对于 workspace 的路径,作为响应中的 path
|
|
510
512
|
const relativePath = nodePath.relative(workspaceDir, resolved);
|
|
511
|
-
await receiveUpload(dc, req, resolved, relativePath);
|
|
513
|
+
await receiveUpload(dc, req, resolved, relativePath, connId);
|
|
512
514
|
}
|
|
513
515
|
|
|
514
516
|
/**
|
|
@@ -517,8 +519,9 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
517
519
|
* @param {object} req - 请求对象(含 size)
|
|
518
520
|
* @param {string} resolved - 目标文件绝对路径
|
|
519
521
|
* @param {string} [relativePath] - POST 时附带的相对路径(响应中返回)
|
|
522
|
+
* @param {string} [connId] - 所属连接 ID
|
|
520
523
|
*/
|
|
521
|
-
async function receiveUpload(dc, req, resolved, relativePath) {
|
|
524
|
+
async function receiveUpload(dc, req, resolved, relativePath, connId) {
|
|
522
525
|
const declaredSize = req.size;
|
|
523
526
|
if (!Number.isFinite(declaredSize) || declaredSize < 0) {
|
|
524
527
|
sendError(dc, 'INVALID_INPUT', 'size must be a non-negative number');
|
|
@@ -550,6 +553,12 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
550
553
|
return;
|
|
551
554
|
}
|
|
552
555
|
|
|
556
|
+
const logTag = connId ? `conn=${connId} ` : '';
|
|
557
|
+
remoteLog(`file.up.start ${logTag}id=${transferId} method=${req.method} size=${declaredSize}`);
|
|
558
|
+
const startTime = Date.now();
|
|
559
|
+
let nextLogAt = declaredSize > 0 ? Math.floor(declaredSize * 0.25) : Infinity;
|
|
560
|
+
let logStep = 1; // 25% → 50% → 75%
|
|
561
|
+
|
|
553
562
|
// 发送就绪信号
|
|
554
563
|
try {
|
|
555
564
|
dc.send(JSON.stringify({ ok: true }));
|
|
@@ -557,12 +566,110 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
557
566
|
// WriteStream 可能尚未完成文件创建,等 close 后再清理
|
|
558
567
|
ws.on('close', () => safeUnlink(tmpPath));
|
|
559
568
|
ws.destroy();
|
|
569
|
+
remoteLog(`file.up.abort ${logTag}id=${transferId} reason=dc-send-failed`);
|
|
560
570
|
return;
|
|
561
571
|
}
|
|
562
572
|
|
|
563
573
|
let receivedBytes = 0;
|
|
564
574
|
let doneReceived = false;
|
|
565
575
|
let dcClosed = false;
|
|
576
|
+
let wsBackpressureCount = 0;
|
|
577
|
+
let wsError = false;
|
|
578
|
+
let finishing = false;
|
|
579
|
+
|
|
580
|
+
// --- 受控写入:中间缓冲 + drain 循环 ---
|
|
581
|
+
const pendingQueue = [];
|
|
582
|
+
let draining = false;
|
|
583
|
+
|
|
584
|
+
function scheduleDrain() {
|
|
585
|
+
if (draining) return;
|
|
586
|
+
draining = true;
|
|
587
|
+
setImmediate(drainLoop);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function drainLoop() {
|
|
591
|
+
if (wsError || dcClosed) { draining = false; return; }
|
|
592
|
+
const chunk = pendingQueue.shift();
|
|
593
|
+
if (!chunk) {
|
|
594
|
+
draining = false;
|
|
595
|
+
// 队列排空且已收到 done → 结束写入
|
|
596
|
+
if (doneReceived) finishUpload();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
let ok;
|
|
600
|
+
try {
|
|
601
|
+
ok = ws.write(chunk);
|
|
602
|
+
} catch (err) {
|
|
603
|
+
// ws 可能已被销毁(如 SIZE_EXCEEDED 路径竞态),防止 gateway 崩溃
|
|
604
|
+
wsError = true;
|
|
605
|
+
draining = false;
|
|
606
|
+
pendingQueue.length = 0;
|
|
607
|
+
log.warn?.(`[coclaw/file] drainLoop write error: ${err.message}`);
|
|
608
|
+
ws.destroy();
|
|
609
|
+
if (!dcClosed) sendError(dc, 'WRITE_FAILED', err.message);
|
|
610
|
+
safeUnlink(tmpPath);
|
|
611
|
+
const elapsed = Date.now() - startTime;
|
|
612
|
+
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=drain-write-error err=${err.message} received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms`);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (!ok) {
|
|
616
|
+
wsBackpressureCount++;
|
|
617
|
+
// 等待 drain 事件后再继续(尊重磁盘 I/O 速度)
|
|
618
|
+
ws.once('drain', () => setImmediate(drainLoop));
|
|
619
|
+
} else {
|
|
620
|
+
// 每次写入后让出 CPU,防止事件循环饥饿
|
|
621
|
+
setImmediate(drainLoop);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function finishUpload() {
|
|
626
|
+
if (finishing) return;
|
|
627
|
+
finishing = true;
|
|
628
|
+
ws.end(async () => {
|
|
629
|
+
const elapsed = Date.now() - startTime;
|
|
630
|
+
if (dcClosed) {
|
|
631
|
+
safeUnlink(tmpPath);
|
|
632
|
+
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed-before-flush received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const valid = receivedBytes === declaredSize;
|
|
636
|
+
if (!valid) {
|
|
637
|
+
try {
|
|
638
|
+
dc.send(JSON.stringify({ ok: false, error: { code: 'WRITE_FAILED', message: `Size mismatch: expected ${declaredSize}, got ${receivedBytes}` } }));
|
|
639
|
+
/* c8 ignore next */
|
|
640
|
+
} catch { /* ignore */ }
|
|
641
|
+
safeUnlink(tmpPath);
|
|
642
|
+
/* c8 ignore next */
|
|
643
|
+
try { dc.close(); } catch { /* ignore */ }
|
|
644
|
+
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=size-mismatch expected=${declaredSize} got=${receivedBytes} elapsed=${elapsed}ms`);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
// 先 rename,再发成功响应(避免 rename 失败时 UI 误认为成功)
|
|
648
|
+
try {
|
|
649
|
+
await _rename(tmpPath, resolved);
|
|
650
|
+
} catch (renameErr) {
|
|
651
|
+
log.warn?.(`[coclaw/file] rename failed: ${renameErr.message}`);
|
|
652
|
+
/* c8 ignore next 3 -- dc.send/close 失败属罕见竞态 */
|
|
653
|
+
try {
|
|
654
|
+
dc.send(JSON.stringify({ ok: false, error: { code: 'WRITE_FAILED', message: `rename failed: ${renameErr.message}` } }));
|
|
655
|
+
} catch { /* ignore */ }
|
|
656
|
+
safeUnlink(tmpPath);
|
|
657
|
+
/* c8 ignore next */
|
|
658
|
+
try { dc.close(); } catch { /* ignore */ }
|
|
659
|
+
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=rename-failed elapsed=${elapsed}ms`);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const result = { ok: true, bytes: receivedBytes };
|
|
663
|
+
if (relativePath) result.path = relativePath;
|
|
664
|
+
try {
|
|
665
|
+
dc.send(JSON.stringify(result));
|
|
666
|
+
/* c8 ignore next */
|
|
667
|
+
} catch { /* ignore */ }
|
|
668
|
+
/* c8 ignore next */
|
|
669
|
+
try { dc.close(); } catch { /* ignore */ }
|
|
670
|
+
remoteLog(`file.up.ok ${logTag}id=${transferId} bytes=${receivedBytes} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
671
|
+
});
|
|
672
|
+
}
|
|
566
673
|
|
|
567
674
|
// 替换原始 onmessage(文件传输模式)
|
|
568
675
|
dc.onmessage = (event) => {
|
|
@@ -571,54 +678,19 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
571
678
|
try { msg = JSON.parse(event.data); } catch { return; }
|
|
572
679
|
if (msg.done) {
|
|
573
680
|
doneReceived = true;
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
safeUnlink(tmpPath);
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
const valid = receivedBytes === declaredSize;
|
|
580
|
-
if (!valid) {
|
|
581
|
-
try {
|
|
582
|
-
dc.send(JSON.stringify({ ok: false, error: { code: 'WRITE_FAILED', message: `Size mismatch: expected ${declaredSize}, got ${receivedBytes}` } }));
|
|
583
|
-
/* c8 ignore next */
|
|
584
|
-
} catch { /* ignore */ }
|
|
585
|
-
safeUnlink(tmpPath);
|
|
586
|
-
/* c8 ignore next */
|
|
587
|
-
try { dc.close(); } catch { /* ignore */ }
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
// 先 rename,再发成功响应(避免 rename 失败时 UI 误认为成功)
|
|
591
|
-
try {
|
|
592
|
-
await _rename(tmpPath, resolved);
|
|
593
|
-
} catch (renameErr) {
|
|
594
|
-
log.warn?.(`[coclaw/file] rename failed: ${renameErr.message}`);
|
|
595
|
-
/* c8 ignore next 3 -- dc.send/close 失败属罕见竞态 */
|
|
596
|
-
try {
|
|
597
|
-
dc.send(JSON.stringify({ ok: false, error: { code: 'WRITE_FAILED', message: `rename failed: ${renameErr.message}` } }));
|
|
598
|
-
} catch { /* ignore */ }
|
|
599
|
-
safeUnlink(tmpPath);
|
|
600
|
-
/* c8 ignore next */
|
|
601
|
-
try { dc.close(); } catch { /* ignore */ }
|
|
602
|
-
return;
|
|
603
|
-
}
|
|
604
|
-
const result = { ok: true, bytes: receivedBytes };
|
|
605
|
-
if (relativePath) result.path = relativePath;
|
|
606
|
-
try {
|
|
607
|
-
dc.send(JSON.stringify(result));
|
|
608
|
-
/* c8 ignore next */
|
|
609
|
-
} catch { /* ignore */ }
|
|
610
|
-
/* c8 ignore next */
|
|
611
|
-
try { dc.close(); } catch { /* ignore */ }
|
|
612
|
-
});
|
|
681
|
+
// 队列已空则立即结束,否则等 drainLoop 排空后处理
|
|
682
|
+
if (pendingQueue.length === 0 && !draining) finishUpload();
|
|
613
683
|
}
|
|
614
684
|
} else {
|
|
615
|
-
// binary 数据帧
|
|
685
|
+
// binary 数据帧 — 入队,由 drainLoop 按节奏写入
|
|
616
686
|
const chunk = event.data;
|
|
617
687
|
const len = chunk.byteLength ?? chunk.length ?? 0;
|
|
618
688
|
receivedBytes += len;
|
|
619
689
|
|
|
620
690
|
// 接收端超限防护
|
|
621
691
|
if (receivedBytes > MAX_UPLOAD_SIZE || receivedBytes > declaredSize) {
|
|
692
|
+
wsError = true;
|
|
693
|
+
pendingQueue.length = 0;
|
|
622
694
|
ws.destroy();
|
|
623
695
|
safeUnlink(tmpPath);
|
|
624
696
|
try {
|
|
@@ -628,28 +700,50 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
628
700
|
}));
|
|
629
701
|
} catch { /* ignore */ }
|
|
630
702
|
try { dc.close(); } catch { /* ignore */ }
|
|
703
|
+
remoteLog(`file.up.reject ${logTag}id=${transferId} reason=size-exceeded received=${receivedBytes}`);
|
|
631
704
|
return;
|
|
632
705
|
}
|
|
633
|
-
|
|
706
|
+
|
|
707
|
+
pendingQueue.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
708
|
+
scheduleDrain();
|
|
709
|
+
|
|
710
|
+
// 进度日志(25% / 50% / 75%)
|
|
711
|
+
if (receivedBytes >= nextLogAt && logStep <= 3) {
|
|
712
|
+
remoteLog(`file.up.progress ${logTag}id=${transferId} ${logStep * 25}% received=${receivedBytes}/${declaredSize} bp=${wsBackpressureCount}`);
|
|
713
|
+
logStep++;
|
|
714
|
+
nextLogAt = declaredSize > 0 ? Math.floor(declaredSize * logStep * 0.25) : Infinity;
|
|
715
|
+
}
|
|
634
716
|
}
|
|
635
717
|
};
|
|
636
718
|
|
|
637
719
|
dc.onclose = () => {
|
|
638
720
|
dcClosed = true;
|
|
639
|
-
|
|
721
|
+
draining = false;
|
|
722
|
+
pendingQueue.length = 0;
|
|
723
|
+
if (doneReceived) {
|
|
724
|
+
// done 已收到但 drain 未完成 — finishUpload 中会检测 dcClosed 并清理 tmp
|
|
725
|
+
if (!finishing) finishUpload();
|
|
726
|
+
} else {
|
|
640
727
|
ws.destroy();
|
|
641
728
|
safeUnlink(tmpPath);
|
|
729
|
+
const elapsed = Date.now() - startTime;
|
|
730
|
+
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
642
731
|
}
|
|
643
732
|
};
|
|
644
733
|
|
|
645
734
|
// WriteStream 错误处理
|
|
646
735
|
ws.on('error', (err) => {
|
|
736
|
+
wsError = true;
|
|
737
|
+
draining = false;
|
|
738
|
+
pendingQueue.length = 0;
|
|
647
739
|
log.warn?.(`[coclaw/file] write stream error: ${err.message}`);
|
|
648
740
|
if (!dcClosed) {
|
|
649
741
|
const code = err.code === 'ENOSPC' ? 'DISK_FULL' : 'WRITE_FAILED';
|
|
650
742
|
sendError(dc, code, err.message);
|
|
651
743
|
}
|
|
652
744
|
safeUnlink(tmpPath);
|
|
745
|
+
const elapsed = Date.now() - startTime;
|
|
746
|
+
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=write-error err=${err.code || err.message} received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms`);
|
|
653
747
|
});
|
|
654
748
|
}
|
|
655
749
|
|
package/src/realtime-bridge.js
CHANGED
|
@@ -24,7 +24,7 @@ const SERVER_HB_MAX_MISS = 4; // 连续 4 次无响应才断连(~3 分钟)
|
|
|
24
24
|
function toServerWsUrl(baseUrl, token) {
|
|
25
25
|
const url = new URL(baseUrl);
|
|
26
26
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
27
|
-
url.pathname = '/api/v1/
|
|
27
|
+
url.pathname = '/api/v1/claws/stream';
|
|
28
28
|
url.searchParams.set('token', token);
|
|
29
29
|
return url.toString();
|
|
30
30
|
}
|
|
@@ -176,13 +176,13 @@ export class RealtimeBridge {
|
|
|
176
176
|
?? DEFAULT_GATEWAY_WS_URL;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
async __clearTokenLocal(
|
|
179
|
+
async __clearTokenLocal(unboundClawId) {
|
|
180
180
|
const cfg = await this.__readConfig();
|
|
181
181
|
if (!cfg?.token) {
|
|
182
182
|
return;
|
|
183
183
|
}
|
|
184
|
-
// 只清除匹配的
|
|
185
|
-
if (
|
|
184
|
+
// 只清除匹配的 claw,避免新绑定被误清
|
|
185
|
+
if (unboundClawId && cfg.clawId && cfg.clawId !== unboundClawId) {
|
|
186
186
|
return;
|
|
187
187
|
}
|
|
188
188
|
await this.__clearConfig();
|
|
@@ -232,8 +232,8 @@ export class RealtimeBridge {
|
|
|
232
232
|
this.__fileHandler.handleRpcRequest(payload, sendFn)
|
|
233
233
|
.catch((err) => this.logger.warn?.(`[coclaw/file] rpc error: ${err.message}`));
|
|
234
234
|
},
|
|
235
|
-
onFileChannel: (dc) => {
|
|
236
|
-
this.__fileHandler.handleFileChannel(dc);
|
|
235
|
+
onFileChannel: (dc, connId) => {
|
|
236
|
+
this.__fileHandler.handleFileChannel(dc, connId);
|
|
237
237
|
},
|
|
238
238
|
PeerConnection,
|
|
239
239
|
logger: this.logger,
|
|
@@ -824,10 +824,10 @@ export class RealtimeBridge {
|
|
|
824
824
|
this.__resetServerHbTimeout(sock);
|
|
825
825
|
try {
|
|
826
826
|
const payload = JSON.parse(String(event.data ?? '{}'));
|
|
827
|
-
if (payload?.type === '
|
|
828
|
-
remoteLog('ws.
|
|
829
|
-
await this.__clearTokenLocal(payload.
|
|
830
|
-
try { sock.close(4001, '
|
|
827
|
+
if (payload?.type === 'claw.unbound') {
|
|
828
|
+
remoteLog('ws.claw-unbound');
|
|
829
|
+
await this.__clearTokenLocal(payload.clawId);
|
|
830
|
+
try { sock.close(4001, 'claw_unbound'); }
|
|
831
831
|
/* c8 ignore next */
|
|
832
832
|
catch {}
|
|
833
833
|
return;
|
|
@@ -994,7 +994,7 @@ export class RealtimeBridge {
|
|
|
994
994
|
if (sock) {
|
|
995
995
|
this.intentionallyClosed = true;
|
|
996
996
|
this.serverWs = null;
|
|
997
|
-
// 等待 WebSocket 真正关闭,避免残留连接收到
|
|
997
|
+
// 等待 WebSocket 真正关闭,避免残留连接收到 claw.unbound 等消息
|
|
998
998
|
/* c8 ignore next -- readyState === 3 时跳过 */
|
|
999
999
|
if (sock.readyState === 3) return;
|
|
1000
1000
|
await new Promise((resolve) => {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|