cc-viewer 1.6.294 → 1.6.296

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/cli.js +7 -2
  2. package/dist/assets/App-BeCGow-I.js +2 -0
  3. package/dist/assets/{MdxEditorPanel-B8xrlDZJ.js → MdxEditorPanel-D52b5qxi.js} +1 -1
  4. package/dist/assets/{Mobile-fsi8-Lpb.js → Mobile-8fflztx7.js} +1 -1
  5. package/dist/assets/index-DtpelJc4.js +2 -0
  6. package/dist/assets/seqResourceLoaders-DM-48tr-.js +2 -0
  7. package/dist/index.html +1 -1
  8. package/findcc.js +3 -3
  9. package/package.json +1 -1
  10. package/server/i18n.js +224 -8
  11. package/server/interceptor.js +23 -19
  12. package/server/lib/adapters/dingtalk-adapter.js +62 -0
  13. package/server/lib/adapters/discord-adapter.js +35 -0
  14. package/server/lib/adapters/feishu-adapter.js +37 -0
  15. package/server/lib/ask-store.js +19 -90
  16. package/server/lib/async-file-lock.js +123 -0
  17. package/server/lib/async-write-queue.js +131 -0
  18. package/server/lib/git-diff.js +4 -1
  19. package/server/lib/im-bridge-core.js +119 -14
  20. package/server/lib/im-config.js +11 -6
  21. package/server/lib/im-process-manager.js +1 -1
  22. package/server/lib/jsonl-archive.js +0 -1
  23. package/server/lib/log-management.js +46 -99
  24. package/server/lib/log-stream.js +102 -8
  25. package/server/lib/log-watcher.js +231 -178
  26. package/server/lib/plugin-manager.js +1 -1
  27. package/server/lib/updater.js +4 -2
  28. package/server/pty-manager.js +1 -1
  29. package/server/routes/ask-perm.js +2 -2
  30. package/server/routes/dingtalk.js +2 -0
  31. package/server/routes/events.js +3 -3
  32. package/server/routes/files-fs.js +4 -4
  33. package/server/routes/logs.js +5 -5
  34. package/server/routes/project-meta.js +18 -1
  35. package/server/routes/workspaces.js +10 -13
  36. package/server/server.js +33 -25
  37. package/server/workspace-registry.js +26 -72
  38. package/dist/assets/App-C66LoBEz.js +0 -2
  39. package/dist/assets/index-BTZqk5O5.js +0 -2
  40. package/dist/assets/seqResourceLoaders-6k4uXcNn.js +0 -2
@@ -30,7 +30,9 @@ const SEEN_MAX = 500;
30
30
  const RATE_WINDOW_MS = 60_000;
31
31
  const MAX_CHUNKS_PER_TURN = 5;
32
32
  const MAX_QUEUE = 50; // cap inbound backlog so an authorized sender can't grow it unbounded
33
- const PENDING_TIMEOUT_MS = 10 * 60_000; // release a stuck injection so the queue can't wedge forever
33
+ const PENDING_TIMEOUT_MS = process.env.CCV_IM_PLATFORM ? 2 * 60_000 : 10 * 60_000;
34
+ const IDLE_POLL_INTERVAL_MS = 5_000; // check every 5s if streaming stopped
35
+ const IDLE_POLL_THRESHOLD = 3; // 3 consecutive idle ticks (15s) → synthetic turn_end
34
36
  const CONNECT_TIMEOUT_MS = 15_000; // bound adapter.connect() so a hung start can't block others
35
37
  const STOP_WORDS = new Set(['/stop', 'stop', '停止', 'esc', '/esc']);
36
38
 
@@ -38,6 +40,8 @@ const STOP_WORDS = new Set(['/stop', 'stop', '停止', 'esc', '/esc']);
38
40
  const instances = new Map(); // platformId → instance
39
41
  let activeInjection = null; // { platformId, since, target } — the one in-flight turn
40
42
  let activeInjectionTimer = null; // self-heal timer if a turn_end never arrives
43
+ let idlePollTimer = null; // secondary idle detection (IM worker only)
44
+ let idlePollCount = 0; // consecutive ticks where isStreaming() is false
41
45
  let fetchImpl = null; // shared test seam
42
46
 
43
47
  // ─── test seams ───
@@ -59,6 +63,7 @@ function newInstance(adapter) {
59
63
  queue: [],
60
64
  sendTimes: [],
61
65
  store: {}, // adapter scratch (token cache, send client)
66
+ ackCardPromise: null, // Promise<handle|null> for the in-flight ack card
62
67
  };
63
68
  }
64
69
 
@@ -143,22 +148,63 @@ function queueCap(inst) {
143
148
  return inst.maxQueueOverride ?? MAX_QUEUE;
144
149
  }
145
150
 
151
+ /**
152
+ * Await the in-flight ack card promise, update it with terminal text/status, and null
153
+ * the promise on `inst`. Returns true if the card was successfully updated. Best-effort:
154
+ * never throws, never blocks the slot release.
155
+ */
156
+ async function finalizeAckCard(inst, target, text, status) {
157
+ try {
158
+ const handle = await inst.ackCardPromise?.catch(() => null);
159
+ inst.ackCardPromise = null;
160
+ if (handle && typeof inst.adapter.updateAckCard === 'function') {
161
+ const cfg = inst.bridgeDeps?.getConfig();
162
+ if (cfg) return !!(await inst.adapter.updateAckCard(cfg, target, handle, text, status, ctxFor(inst)).catch(() => false));
163
+ }
164
+ } catch { /* best-effort */ }
165
+ inst.ackCardPromise = null;
166
+ return false;
167
+ }
168
+
146
169
  // ─── activeInjection lifecycle ───
147
170
  function clearActiveInjection() {
148
171
  activeInjection = null;
149
172
  if (activeInjectionTimer) { clearTimeout(activeInjectionTimer); activeInjectionTimer = null; }
173
+ if (idlePollTimer) { clearInterval(idlePollTimer); idlePollTimer = null; }
174
+ idlePollCount = 0;
150
175
  }
151
176
  function armActiveInjection(inst, target, since) {
152
- activeInjection = { platformId: inst.adapter.id, since, target };
177
+ activeInjection = { platformId: inst.adapter.id, since, target, transcriptPath: null };
153
178
  if (activeInjectionTimer) clearTimeout(activeInjectionTimer);
154
- activeInjectionTimer = setTimeout(() => {
179
+ activeInjectionTimer = setTimeout(async () => {
155
180
  // Only fire if THIS injection still owns the slot (symmetry with the inject-failure guard).
156
181
  if (!activeInjection || activeInjection.since !== since) return;
157
182
  audit(inst, 'reply-timeout', { conversationId: target?.conversationId });
158
- clearActiveInjection(); // turn_end never came release the slot globally…
183
+ const cardUpdated = await finalizeAckCard(inst, target, tr(inst, 'ackTimeout'), 'error');
184
+ if (!activeInjection || activeInjection.since !== since) return;
185
+ clearActiveInjection();
186
+ if (!cardUpdated) void sendReply(inst, target, tr(inst, 'ackTimeout'));
159
187
  drainAll(); // …and let any platform's queue proceed
160
188
  }, PENDING_TIMEOUT_MS);
161
189
  if (typeof activeInjectionTimer.unref === 'function') activeInjectionTimer.unref();
190
+ // Secondary idle detection: poll isStreaming() to catch missed Stop hook events.
191
+ if (idlePollTimer) clearInterval(idlePollTimer);
192
+ idlePollCount = 0;
193
+ let sawStreaming = false;
194
+ idlePollTimer = setInterval(() => {
195
+ if (!activeInjection || activeInjection.since !== since) { clearInterval(idlePollTimer); idlePollTimer = null; return; }
196
+ const d = inst.bridgeDeps;
197
+ if (!d) return;
198
+ if (d.isStreaming()) { sawStreaming = true; idlePollCount = 0; return; }
199
+ if (!sawStreaming) return; // haven't seen streaming start yet — don't count idle
200
+ idlePollCount++;
201
+ if (idlePollCount >= IDLE_POLL_THRESHOLD) {
202
+ clearInterval(idlePollTimer); idlePollTimer = null;
203
+ audit(inst, 'idle-turn-end', { conversationId: target?.conversationId, idleSeconds: idlePollCount * IDLE_POLL_INTERVAL_MS / 1000 });
204
+ notifyTurnEnd(null, since, activeInjection?.transcriptPath || null);
205
+ }
206
+ }, IDLE_POLL_INTERVAL_MS);
207
+ if (typeof idlePollTimer.unref === 'function') idlePollTimer.unref();
162
208
  }
163
209
 
164
210
  // ─── small helpers ───
@@ -212,8 +258,8 @@ function isStopCommand(text) {
212
258
  return STOP_WORDS.has(text.trim().toLowerCase());
213
259
  }
214
260
 
215
- function tr(inst, key) {
216
- return t(`${inst.adapter.i18nNs}.${key}`);
261
+ function tr(inst, key, params) {
262
+ return t(`${inst.adapter.i18nNs}.${key}`, params);
217
263
  }
218
264
 
219
265
  // ─── transcript extraction (the safe outbound text source) ───
@@ -369,6 +415,11 @@ function handleInboundInner(inst, normalized) {
369
415
  if (isStopCommand(text)) {
370
416
  inst.bridgeDeps.writeToPty('\x1b'); // ESC interrupts the current turn (NOT killPty)
371
417
  audit(inst, 'stop', { conversationId });
418
+ const stoppedInst = activeInjection ? instances.get(activeInjection.platformId) : null;
419
+ const stoppedTarget = activeInjection?.target;
420
+ if (stoppedInst && stoppedTarget) {
421
+ void finalizeAckCard(stoppedInst, stoppedTarget, tr(stoppedInst, 'interrupted'), 'interrupted');
422
+ }
372
423
  // ESC interrupts whatever turn is live on the shared PTY (possibly another platform's), which
373
424
  // may mean its turn_end never fires. Release the global slot and resume all queues so /stop
374
425
  // can never wedge the bridge.
@@ -380,12 +431,13 @@ function handleInboundInner(inst, normalized) {
380
431
 
381
432
  if (inst.queue.length >= queueCap(inst)) {
382
433
  audit(inst, 'queue-full', { conversationId, queued: inst.queue.length });
383
- void sendReply(inst, target, tr(inst, 'queueFull'));
434
+ void sendReply(inst, target, tr(inst, 'queueFull', { max: String(queueCap(inst)) }));
384
435
  return;
385
436
  }
386
437
  inst.queue.push({ ...target, senderId, content: text });
387
438
  if (activeInjection || inst.bridgeDeps.isStreaming()) {
388
- void sendReply(inst, target, tr(inst, 'busyQueued'));
439
+ const ahead = inst.queue.length - 1;
440
+ void sendReply(inst, target, tr(inst, 'busyQueued', { ahead: String(ahead), max: String(queueCap(inst)) }));
389
441
  }
390
442
  drainQueue(inst);
391
443
  }
@@ -419,7 +471,19 @@ function drainQueue(inst) {
419
471
  }
420
472
  const since = Date.now();
421
473
  armActiveInjection(inst, item, since);
422
- if (!isImWorker && skipPerm) {
474
+ // Instant ack: fire-and-forget so writeToPtySequential is never delayed.
475
+ if (cfg.ackCard !== false && typeof inst.adapter.sendAckCard === 'function') {
476
+ const ackTarget = item;
477
+ inst.ackCardPromise = inst.adapter.sendAckCard(cfg, item, tr(inst, 'ackProcessing'), ctxFor(inst))
478
+ .then((handle) => { if (!handle) void sendReply(inst, ackTarget, tr(inst, 'ackProcessing')); return handle; })
479
+ .catch((e) => { audit(inst, 'ack-card-error', { error: String(e?.message || e) }); void sendReply(inst, ackTarget, tr(inst, 'ackProcessing')); return null; });
480
+ } else if (cfg.ackCard !== false) {
481
+ void sendReply(inst, item, tr(inst, 'ackProcessing'));
482
+ inst.ackCardPromise = null;
483
+ } else {
484
+ inst.ackCardPromise = null;
485
+ }
486
+ if (!isImWorker && skipPerm && cfg.ackCard === false) {
423
487
  audit(inst, 'skip-perm-warning', { conversationId: item.conversationId });
424
488
  void sendReply(inst, item, tr(inst, 'skipPermWarning'));
425
489
  }
@@ -430,9 +494,13 @@ function drainQueue(inst) {
430
494
  if (ok) return;
431
495
  if (!activeInjection || activeInjection.platformId !== inst.adapter.id || activeInjection.since !== since) return;
432
496
  audit(inst, 'inject-failed', { conversationId: item.conversationId });
433
- clearActiveInjection();
434
- void sendReply(inst, item, tr(inst, 'injectFailed'));
435
- drainAll();
497
+ void (async () => {
498
+ const cardUpdated = await finalizeAckCard(inst, item, tr(inst, 'injectFailed'), 'error');
499
+ if (!activeInjection || activeInjection.platformId !== inst.adapter.id || activeInjection.since !== since) return;
500
+ clearActiveInjection();
501
+ if (!cardUpdated) void sendReply(inst, item, tr(inst, 'injectFailed'));
502
+ drainAll();
503
+ })().catch(() => {});
436
504
  }, { settleMs: 250 });
437
505
  return; // one at a time; resume on the next turn_end
438
506
  }
@@ -447,6 +515,7 @@ function drainAll() {
447
515
 
448
516
  // ─── outbound trigger (called from server.js _emitTurnEnd) ───
449
517
  export async function notifyTurnEnd(sessionId, ts, transcriptPath) {
518
+ if (transcriptPath && activeInjection) activeInjection.transcriptPath = transcriptPath;
450
519
  if (!activeInjection) { drainAll(); return; } // only reply to turns a bridge initiated
451
520
  const inst = instances.get(activeInjection.platformId);
452
521
  if (!inst) { clearActiveInjection(); drainAll(); return; }
@@ -455,6 +524,9 @@ export async function notifyTurnEnd(sessionId, ts, transcriptPath) {
455
524
  // correlation is a v2 item.)
456
525
  if (ts && activeInjection.since && ts < activeInjection.since) { drainAll(); return; }
457
526
  const target = activeInjection.target;
527
+ // Grab the ack card promise before clearing state.
528
+ const ackP = inst.ackCardPromise;
529
+ inst.ackCardPromise = null;
458
530
  clearActiveInjection();
459
531
  // Idempotency for a doubled turn_end of the SAME turn (a re-broadcast carries the same ts).
460
532
  // Keyed on ts, NOT reply text.
@@ -466,8 +538,36 @@ export async function notifyTurnEnd(sessionId, ts, transcriptPath) {
466
538
  drainAll();
467
539
  let text = extractLastAssistantText(transcriptPath);
468
540
  if (!text) text = tr(inst, 'noTextReply');
469
- try { await sendReply(inst, target, text); }
470
- catch (e) { inst.lastError = String(e?.message || e); audit(inst, 'send-error', { error: inst.lastError }); }
541
+
542
+ // Try to update the ack card in-place with the reply. Fall back to sendReply on failure.
543
+ const handle = await ackP?.catch(() => null);
544
+ if (handle && typeof inst.adapter.updateAckCard === 'function') {
545
+ const cfg = inst.bridgeDeps.getConfig();
546
+ let chunks = chunkText(text, cfg.maxChunkChars);
547
+ if (chunks.length > MAX_CHUNKS_PER_TURN) {
548
+ chunks = chunks.slice(0, MAX_CHUNKS_PER_TURN);
549
+ chunks[MAX_CHUNKS_PER_TURN - 1] += '\n\n' + tr(inst, 'truncated');
550
+ }
551
+ try {
552
+ const updated = await inst.adapter.updateAckCard(cfg, target, handle, chunks[0] || text, 'done', ctxFor(inst));
553
+ if (updated && chunks.length > 1) {
554
+ for (let i = 1; i < chunks.length; i++) {
555
+ try { await rateLimitGate(inst); await inst.adapter.sendOne(cfg, target, chunks[i], ctxFor(inst)); }
556
+ catch (e) { inst.lastError = String(e?.message || e); audit(inst, 'send-error', { error: inst.lastError }); break; }
557
+ }
558
+ } else if (!updated) {
559
+ await sendReply(inst, target, text);
560
+ }
561
+ audit(inst, 'out', { conversationId: target.conversationId, chunks: chunks.length, cardUpdated: !!updated });
562
+ } catch (e) {
563
+ inst.lastError = String(e?.message || e);
564
+ audit(inst, 'card-update-error', { error: inst.lastError });
565
+ try { await sendReply(inst, target, text); } catch { /* already logged in sendReply */ }
566
+ }
567
+ } else {
568
+ try { await sendReply(inst, target, text); }
569
+ catch (e) { inst.lastError = String(e?.message || e); audit(inst, 'send-error', { error: inst.lastError }); }
570
+ }
471
571
  }
472
572
 
473
573
  // ─── per-platform lifecycle ───
@@ -504,6 +604,11 @@ export async function startBridge(id, deps) {
504
604
  export async function stopBridge(id) {
505
605
  const inst = instances.get(id);
506
606
  if (!inst) return;
607
+ if (inst.ackCardPromise && activeInjection?.platformId === id) {
608
+ await finalizeAckCard(inst, activeInjection.target, tr(inst, 'noSession'), 'error');
609
+ } else {
610
+ inst.ackCardPromise = null;
611
+ }
507
612
  try { await inst.adapter.disconnect?.(inst.client, ctxFor(inst)); } catch { /* best-effort */ }
508
613
  inst.client = null;
509
614
  inst.running = false;
@@ -34,7 +34,7 @@ const DESCRIPTORS = {
34
34
  allowListField: 'allowStaffIds',
35
35
  defaults: {
36
36
  enabled: false, appKey: '', appSecret: '', allowStaffIds: [],
37
- maxChunkChars: 3800, blockOnSkipPermissions: false,
37
+ maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true, cardTemplateId: '',
38
38
  },
39
39
  fields: [
40
40
  { key: 'enabled', type: 'bool' },
@@ -43,6 +43,8 @@ const DESCRIPTORS = {
43
43
  { key: 'allowStaffIds', type: 'idlist' },
44
44
  { key: 'maxChunkChars', type: 'chunk' },
45
45
  { key: 'blockOnSkipPermissions', type: 'bool' },
46
+ { key: 'ackCard', type: 'bool', default: true },
47
+ { key: 'cardTemplateId', type: 'string' },
46
48
  ],
47
49
  },
48
50
  feishu: {
@@ -50,7 +52,7 @@ const DESCRIPTORS = {
50
52
  allowListField: 'allowUserIds',
51
53
  defaults: {
52
54
  enabled: false, appId: '', appSecret: '', region: 'feishu', allowUserIds: [],
53
- maxChunkChars: 3800, blockOnSkipPermissions: false,
55
+ maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true,
54
56
  },
55
57
  fields: [
56
58
  { key: 'enabled', type: 'bool' },
@@ -60,6 +62,7 @@ const DESCRIPTORS = {
60
62
  { key: 'allowUserIds', type: 'idlist' },
61
63
  { key: 'maxChunkChars', type: 'chunk' },
62
64
  { key: 'blockOnSkipPermissions', type: 'bool' },
65
+ { key: 'ackCard', type: 'bool', default: true },
63
66
  ],
64
67
  },
65
68
  wecom: {
@@ -67,7 +70,7 @@ const DESCRIPTORS = {
67
70
  allowListField: 'allowUserIds',
68
71
  defaults: {
69
72
  enabled: false, botId: '', secret: '', allowUserIds: [],
70
- maxChunkChars: 3800, blockOnSkipPermissions: false,
73
+ maxChunkChars: 3800, blockOnSkipPermissions: false, ackCard: true,
71
74
  },
72
75
  fields: [
73
76
  { key: 'enabled', type: 'bool' },
@@ -76,6 +79,7 @@ const DESCRIPTORS = {
76
79
  { key: 'allowUserIds', type: 'idlist' },
77
80
  { key: 'maxChunkChars', type: 'chunk' },
78
81
  { key: 'blockOnSkipPermissions', type: 'bool' },
82
+ { key: 'ackCard', type: 'bool', default: true },
79
83
  ],
80
84
  },
81
85
  discord: {
@@ -84,7 +88,7 @@ const DESCRIPTORS = {
84
88
  defaults: {
85
89
  // 1900 < Discord's hard 2000-char/message limit (the adapter also hard-splits as defense).
86
90
  enabled: false, botToken: '', allowUserIds: [],
87
- maxChunkChars: 1900, blockOnSkipPermissions: false,
91
+ maxChunkChars: 1900, blockOnSkipPermissions: false, ackCard: true,
88
92
  },
89
93
  fields: [
90
94
  { key: 'enabled', type: 'bool' },
@@ -92,6 +96,7 @@ const DESCRIPTORS = {
92
96
  { key: 'allowUserIds', type: 'idlist' },
93
97
  { key: 'maxChunkChars', type: 'chunk', default: 1900 }, // < Discord's 2000-char limit
94
98
  { key: 'blockOnSkipPermissions', type: 'bool' },
99
+ { key: 'ackCard', type: 'bool', default: true },
95
100
  ],
96
101
  },
97
102
  };
@@ -155,7 +160,7 @@ function normalizeIdList(v) {
155
160
 
156
161
  function normField(type, v, dflt) {
157
162
  switch (type) {
158
- case 'bool': return !!v;
163
+ case 'bool': return v !== undefined && v !== null ? !!v : (dflt !== undefined ? !!dflt : false);
159
164
  case 'cred':
160
165
  case 'secret': return typeof v === 'string' ? v.trim() : '';
161
166
  case 'idlist': return normalizeIdList(v);
@@ -169,7 +174,7 @@ function decodeField(type, v, dflt) {
169
174
  switch (type) {
170
175
  case 'cred':
171
176
  case 'secret': return decodeSecret(v);
172
- case 'bool': return !!v;
177
+ case 'bool': return v !== undefined && v !== null ? !!v : (dflt !== undefined ? !!dflt : false);
173
178
  case 'idlist': return normalizeIdList(v);
174
179
  case 'chunk': return clampChunk(v, dflt);
175
180
  case 'region': return v === 'lark' ? 'lark' : 'feishu';
@@ -22,7 +22,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
22
  export function resolveNodeBinary() {
23
23
  if (!process.versions.electron) return process.execPath;
24
24
  try {
25
- const out = execSync(process.platform === 'win32' ? 'where node' : 'which node', { encoding: 'utf-8' });
25
+ const out = execSync(process.platform === 'win32' ? 'where node' : 'which node', { encoding: 'utf-8', windowsHide: true });
26
26
  const p = process.platform === 'win32' ? out.split('\n')[0].trim() : out.trim();
27
27
  if (p) return p;
28
28
  } catch { /* fall through */ }
@@ -20,7 +20,6 @@ const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
20
20
  const ARCHIVE_MAX_BYTES = 400 * 1024 * 1024;
21
21
 
22
22
  function syncSleep(ms) {
23
- // Atomics.wait 在主线程上真睡眠(不像 busy-wait 占满 CPU 或阻塞 event-loop heuristics)
24
23
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
25
24
  }
26
25
 
@@ -1,17 +1,12 @@
1
- import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, unlinkSync, realpathSync, appendFileSync } from 'node:fs';
1
+ import { existsSync, realpathSync, unlinkSync, readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
2
+ import { readFile, writeFile, appendFile, stat, readdir } from 'node:fs/promises';
3
+ import { randomBytes } from 'node:crypto';
2
4
  import { renameSyncWithRetry } from './file-api.js';
3
5
  import { join } from 'node:path';
4
6
  import { reconstructEntries } from './delta-reconstructor.js';
5
- import { streamReconstructedEntries } from './log-stream.js';
7
+ import { streamReconstructedEntriesAsync } from './log-stream.js';
6
8
  import { archiveJsonl, resolveJsonlPath } from './jsonl-archive.js';
7
9
 
8
- /**
9
- * Validate that a resolved file path is contained within logDir.
10
- * Throws on invalid path (not found or path traversal).
11
- * @param {string} logDir - base log directory
12
- * @param {string} file - relative file path (e.g. "project/file.jsonl")
13
- * @returns {string} the real (resolved) path
14
- */
15
10
  export function validateLogPath(logDir, file) {
16
11
  const filePath = join(logDir, file);
17
12
  if (!existsSync(filePath)) {
@@ -33,65 +28,51 @@ function isLogFileName(name) {
33
28
  return name.endsWith('.jsonl') || name.endsWith('.jsonl.zip');
34
29
  }
35
30
 
36
- /**
37
- * List local log files grouped by project.
38
- * @param {string} logDir - base log directory
39
- * @param {string} currentProjectName - current project name (may be empty)
40
- * @returns {{ [project: string]: Array, _currentProject: string }}
41
- */
42
- export function listLocalLogs(logDir, currentProjectName) {
31
+ export async function listLocalLogs(logDir, currentProjectName) {
43
32
  const grouped = {};
44
- if (existsSync(logDir)) {
45
- const entries = readdirSync(logDir, { withFileTypes: true });
46
- for (const entry of entries) {
47
- if (!entry.isDirectory()) continue;
48
- const project = entry.name;
49
- const projectDir = join(logDir, project);
50
- const files = readdirSync(projectDir)
51
- .filter(isLogFileName)
52
- .sort()
53
- .reverse();
54
- // 从项目统计缓存中读取 per-file 数据,避免逐文件扫描
55
- let statsFiles = null;
56
- try {
57
- const statsFile = join(projectDir, `${project}.json`);
58
- if (existsSync(statsFile)) {
59
- statsFiles = JSON.parse(readFileSync(statsFile, 'utf-8')).files;
60
- }
61
- } catch { }
62
- for (const f of files) {
63
- const match = f.match(/^(.+?)_(\d{8}_\d{6})\.jsonl(\.zip)?$/);
64
- if (!match) continue;
65
- const ts = match[2];
66
- const archived = !!match[3];
67
- const filePath = join(projectDir, f);
68
- const size = statSync(filePath).size;
69
- if (size === 0) continue; // 跳过空文件
70
- // 归档前的统计缓存 key 是 `.jsonl`;归档后切到 `.jsonl.zip`;两种都尝试
71
- const stats = statsFiles?.[f] || (archived ? statsFiles?.[f.slice(0, -4)] : null);
72
- const turns = stats?.summary?.sessionCount || 0;
73
- if (!grouped[project]) grouped[project] = [];
74
- grouped[project].push({ file: `${project}/${f}`, timestamp: ts, size, turns, preview: stats?.preview || [], archived });
33
+ if (!existsSync(logDir)) return { ...grouped, _currentProject: currentProjectName || '' };
34
+
35
+ const entries = await readdir(logDir, { withFileTypes: true });
36
+ for (const entry of entries) {
37
+ if (!entry.isDirectory()) continue;
38
+ const project = entry.name;
39
+ const projectDir = join(logDir, project);
40
+ const files = (await readdir(projectDir))
41
+ .filter(isLogFileName)
42
+ .sort()
43
+ .reverse();
44
+ let statsFiles = null;
45
+ try {
46
+ const statsFile = join(projectDir, `${project}.json`);
47
+ if (existsSync(statsFile)) {
48
+ statsFiles = JSON.parse(await readFile(statsFile, 'utf-8')).files;
75
49
  }
50
+ } catch { }
51
+ for (const f of files) {
52
+ const match = f.match(/^(.+?)_(\d{8}_\d{6})\.jsonl(\.zip)?$/);
53
+ if (!match) continue;
54
+ const ts = match[2];
55
+ const archived = !!match[3];
56
+ const filePath = join(projectDir, f);
57
+ let size;
58
+ try { size = (await stat(filePath)).size; } catch { continue; }
59
+ if (size === 0) continue;
60
+ const stats = statsFiles?.[f] || (archived ? statsFiles?.[f.slice(0, -4)] : null);
61
+ const turns = stats?.summary?.sessionCount || 0;
62
+ if (!grouped[project]) grouped[project] = [];
63
+ grouped[project].push({ file: `${project}/${f}`, timestamp: ts, size, turns, preview: stats?.preview || [], archived });
76
64
  }
77
65
  }
78
66
  return { ...grouped, _currentProject: currentProjectName || '' };
79
67
  }
80
68
 
81
- /**
82
- * Read and parse a local log file.
83
- * @param {string} logDir - base log directory
84
- * @param {string} file - relative file path (e.g. "project/file.jsonl")
85
- * @returns {Array<Object>} parsed entries
86
- */
87
- export function readLocalLog(logDir, file) {
69
+ export async function readLocalLog(logDir, file) {
88
70
  validateLogPath(logDir, file);
89
71
  const filePath = resolveJsonlPath(join(logDir, file));
90
- const content = readFileSync(filePath, 'utf-8');
72
+ const content = await readFile(filePath, 'utf-8');
91
73
  const parsed = content.split('\n---\n').filter(line => line.trim()).map(entry => {
92
74
  try { return JSON.parse(entry); } catch { return null; }
93
75
  }).filter(Boolean);
94
- // Delta storage: 先去重(timestamp|url),再重建 delta 条目
95
76
  const map = new Map();
96
77
  for (const entry of parsed) {
97
78
  const key = `${entry.timestamp}|${entry.url}`;
@@ -100,12 +81,6 @@ export function readLocalLog(logDir, file) {
100
81
  return reconstructEntries(Array.from(map.values()));
101
82
  }
102
83
 
103
- /**
104
- * Delete log files. Returns per-file results.
105
- * @param {string} logDir - base log directory
106
- * @param {string[]} files - array of relative file paths
107
- * @returns {Array<{ file: string, ok?: boolean, error?: string }>}
108
- */
109
84
  export function deleteLogFiles(logDir, files) {
110
85
  const results = [];
111
86
  for (const file of files) {
@@ -134,21 +109,12 @@ export function deleteLogFiles(logDir, files) {
134
109
  return results;
135
110
  }
136
111
 
137
- /**
138
- * Merge multiple log files into the first one, deleting the rest.
139
- * @param {string} logDir - base log directory
140
- * @param {string[]} files - array of relative file paths (at least 2, same project, chronological order)
141
- * @returns {string} the merged target file path (relative)
142
- */
143
- export function mergeLogFiles(logDir, files) {
112
+ export async function mergeLogFiles(logDir, files) {
144
113
  if (!Array.isArray(files) || files.length < 2) {
145
114
  const err = new Error('At least 2 files required');
146
115
  err.code = 'INVALID_INPUT';
147
116
  throw err;
148
117
  }
149
- // 拒绝归档文件参与合并:mergeLogFiles 会以 files[0] 路径写入 plain jsonl 内容,若该路径
150
- // 是 .jsonl.zip 会把 zip 文件覆写成裸文本破坏归档;且合并产物语义上应该是可继续追加的
151
- // 活动文件,与"归档=只读快照"语义冲突。前端 UI 已 disabled,此处后端兜底。
152
118
  for (const f of files) {
153
119
  if (typeof f === 'string' && f.endsWith('.jsonl.zip')) {
154
120
  const err = new Error('Cannot merge archived (.jsonl.zip) files');
@@ -156,15 +122,12 @@ export function mergeLogFiles(logDir, files) {
156
122
  throw err;
157
123
  }
158
124
  }
159
- // 校验所有文件属于同一 project
160
- // 兼容 Win backslash:files 内部可能是 `project\log.json`,按两种 sep 都切才能拿 project 段。
161
125
  const projects = new Set(files.map(f => f.split(/[\\/]/)[0]));
162
126
  if (projects.size !== 1) {
163
127
  const err = new Error('All files must belong to the same project');
164
128
  err.code = 'INVALID_INPUT';
165
129
  throw err;
166
130
  }
167
- // 校验文件存在且无路径穿越
168
131
  for (const f of files) {
169
132
  if (f.includes('..')) {
170
133
  const err = new Error('Invalid file path');
@@ -177,26 +140,23 @@ export function mergeLogFiles(logDir, files) {
177
140
  throw err;
178
141
  }
179
142
  }
180
- // 校验合并后总大小不超过 400MB
181
143
  const MAX_MERGE_SIZE = 400 * 1024 * 1024;
182
144
  let totalSize = 0;
183
145
  for (const f of files) {
184
- totalSize += statSync(join(logDir, f)).size;
146
+ totalSize += (await stat(join(logDir, f))).size;
185
147
  }
186
148
  if (totalSize > MAX_MERGE_SIZE) {
187
149
  const err = new Error(`Merged size (${(totalSize / 1024 / 1024).toFixed(1)}MB) exceeds ${MAX_MERGE_SIZE / 1024 / 1024}MB limit`);
188
150
  err.code = 'INVALID_INPUT';
189
151
  throw err;
190
152
  }
191
- // Delta storage: 流式合并 — 逐文件分段重建并直接写入目标文件,避免全量加载 OOM
192
153
  const targetFile = files[0];
193
154
  const targetPath = join(logDir, targetFile);
194
- // 先写到临时文件,成功后再覆盖目标
195
- const tmpPath = targetPath + '.merge-tmp';
196
- writeFileSync(tmpPath, ''); // 创建空临时文件
155
+ const tmpPath = `${targetPath}.merge-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
156
+ await writeFile(tmpPath, '');
197
157
  for (const f of files) {
198
158
  const filePath = join(logDir, f);
199
- streamReconstructedEntries(filePath, (segment) => {
159
+ await streamReconstructedEntriesAsync(filePath, async (segment) => {
200
160
  let chunk = '';
201
161
  for (const entry of segment) {
202
162
  delete entry._deltaFormat;
@@ -205,12 +165,10 @@ export function mergeLogFiles(logDir, files) {
205
165
  delete entry._isCheckpoint;
206
166
  chunk += JSON.stringify(entry) + '\n---\n';
207
167
  }
208
- appendFileSync(tmpPath, chunk);
168
+ await appendFile(tmpPath, chunk);
209
169
  });
210
170
  }
211
- // 临时文件写入成功后原子覆盖目标(POSIX renameSync 自动替换;Windows reader 持锁时 retry)
212
171
  renameSyncWithRetry(tmpPath, targetPath);
213
- // 删除其余文件
214
172
  for (let i = 1; i < files.length; i++) {
215
173
  unlinkSync(join(logDir, files[i]));
216
174
  }
@@ -224,33 +182,23 @@ function migrateStatsCacheKey(projectDir, projectName, oldFileName, newFileName)
224
182
  const stats = JSON.parse(readFileSync(statsFile, 'utf-8'));
225
183
  if (stats?.files?.[oldFileName]) {
226
184
  const entry = stats.files[oldFileName];
227
- // 同步用归档后 .zip 的 size / mtime 覆写 entry,避免 stats-worker 下次扫描时
228
- // 因 size/mtime 不匹配判定 cache stale 触发整文件重解析(大 jsonl 数秒 CPU)。
229
185
  try {
230
186
  const zipStat = statSync(join(projectDir, newFileName));
231
187
  entry.size = zipStat.size;
232
188
  entry.lastModified = zipStat.mtime.toISOString();
233
- } catch { /* zip 不可 stat 时不更新,让 stats 自然重建 */ }
189
+ } catch {}
234
190
  stats.files[newFileName] = entry;
235
191
  delete stats.files[oldFileName];
236
192
  writeFileSync(statsFile, JSON.stringify(stats, null, 2));
237
193
  }
238
- } catch { /* tolerant */ }
194
+ } catch {}
239
195
  }
240
196
 
241
- /**
242
- * 压缩归档多个 .jsonl 文件。每个 project 的最新文件(按文件名 desc 排序后的 logs[0])
243
- * 被拒绝,复用 mergeLogFiles 的"最新不允许"语义。
244
- * @param {string} logDir
245
- * @param {string[]} files - 形如 "project/<name>.jsonl"
246
- * @returns {{ archived: string[], skipped: Array<{file:string,reason:string}>, failed: Array<{file:string,reason:string}> }}
247
- */
248
197
  export function archiveLogFiles(logDir, files) {
249
198
  const archived = [];
250
199
  const skipped = [];
251
200
  const failed = [];
252
201
 
253
- // 按 project 分组以判定最新文件
254
202
  const byProject = new Map();
255
203
  for (const f of files) {
256
204
  if (!f || typeof f !== 'string' || f.includes('..') || !f.endsWith('.jsonl')) {
@@ -280,7 +228,7 @@ export function archiveLogFiles(logDir, files) {
280
228
  .sort()
281
229
  .reverse();
282
230
  latest = projectEntries[0] || null;
283
- } catch { /* directory missing => downstream calls will fail */ }
231
+ } catch {}
284
232
 
285
233
  for (const f of projectFiles) {
286
234
  const fileName = f.split(/[\\/]/).slice(1).join('/');
@@ -309,7 +257,6 @@ export function archiveLogFiles(logDir, files) {
309
257
  } else if (result.skipped) {
310
258
  skipped.push({ file: f, reason: result.skipped });
311
259
  } else {
312
- // archiveJsonl 内部已在 unlink 失败时回滚 zip,此处 fail 即原状态完整保留,用户可重试
313
260
  failed.push({ file: f, reason: result.error || 'archive failed' });
314
261
  }
315
262
  }