@coclaw/openclaw-coclaw 0.12.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.12.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.1",
62
+ "node-datachannel": "0.32.2",
63
63
  "werift": "^0.19.0"
64
64
  },
65
65
  "devDependencies": {
@@ -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
- ws.end(async () => {
575
- if (dcClosed) {
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
- ws.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
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
- if (!doneReceived) {
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
 
@@ -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,