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.
- package/cli.js +7 -2
- package/dist/assets/App-BeCGow-I.js +2 -0
- package/dist/assets/{MdxEditorPanel-B8xrlDZJ.js → MdxEditorPanel-D52b5qxi.js} +1 -1
- package/dist/assets/{Mobile-fsi8-Lpb.js → Mobile-8fflztx7.js} +1 -1
- package/dist/assets/index-DtpelJc4.js +2 -0
- package/dist/assets/seqResourceLoaders-DM-48tr-.js +2 -0
- package/dist/index.html +1 -1
- package/findcc.js +3 -3
- package/package.json +1 -1
- package/server/i18n.js +224 -8
- package/server/interceptor.js +23 -19
- package/server/lib/adapters/dingtalk-adapter.js +62 -0
- package/server/lib/adapters/discord-adapter.js +35 -0
- package/server/lib/adapters/feishu-adapter.js +37 -0
- package/server/lib/ask-store.js +19 -90
- package/server/lib/async-file-lock.js +123 -0
- package/server/lib/async-write-queue.js +131 -0
- package/server/lib/git-diff.js +4 -1
- package/server/lib/im-bridge-core.js +119 -14
- package/server/lib/im-config.js +11 -6
- package/server/lib/im-process-manager.js +1 -1
- package/server/lib/jsonl-archive.js +0 -1
- package/server/lib/log-management.js +46 -99
- package/server/lib/log-stream.js +102 -8
- package/server/lib/log-watcher.js +231 -178
- package/server/lib/plugin-manager.js +1 -1
- package/server/lib/updater.js +4 -2
- package/server/pty-manager.js +1 -1
- package/server/routes/ask-perm.js +2 -2
- package/server/routes/dingtalk.js +2 -0
- package/server/routes/events.js +3 -3
- package/server/routes/files-fs.js +4 -4
- package/server/routes/logs.js +5 -5
- package/server/routes/project-meta.js +18 -1
- package/server/routes/workspaces.js +10 -13
- package/server/server.js +33 -25
- package/server/workspace-registry.js +26 -72
- package/dist/assets/App-C66LoBEz.js +0 -2
- package/dist/assets/index-BTZqk5O5.js +0 -2
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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;
|
package/server/lib/im-config.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 =
|
|
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 +=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
}
|