cc-viewer 1.6.265 → 1.6.267
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/dist/assets/App--UrtrPvl.js +1 -0
- package/dist/assets/{MdxEditorPanel-CVaK7mOf.js → MdxEditorPanel-BxFcM5Yn.js} +1 -1
- package/dist/assets/Mobile-CToTjGT-.js +1 -0
- package/dist/assets/index-DA3EDksT.js +2 -0
- package/dist/assets/{seqResourceLoaders-DmvKKyh9.css → seqResourceLoaders-BZiHSoZE.css} +2 -2
- package/dist/assets/seqResourceLoaders-Dx3AG90r.js +2 -0
- package/dist/index.html +1 -1
- package/i18n.js +18 -18
- package/lib/jsonl-archive.js +181 -0
- package/lib/log-management.js +128 -9
- package/lib/log-stream.js +6 -0
- package/lib/log-watcher.js +4 -0
- package/lib/stats-worker.js +4 -2
- package/lib/zip-safety.js +68 -0
- package/package.json +1 -1
- package/server.js +44 -6
- package/dist/assets/App-Dn8sM_4p.js +0 -1
- package/dist/assets/Mobile-HEclS2va.js +0 -1
- package/dist/assets/index-BOxeMjZZ.js +0 -2
- package/dist/assets/seqResourceLoaders-BGxPc8Yp.js +0 -2
package/dist/index.html
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
if (pick) document.documentElement.setAttribute('data-theme', pick);
|
|
20
20
|
} catch {}
|
|
21
21
|
</script>
|
|
22
|
-
<script type="module" crossorigin src="/assets/index-
|
|
22
|
+
<script type="module" crossorigin src="/assets/index-DA3EDksT.js"></script>
|
|
23
23
|
<link rel="modulepreload" crossorigin href="/assets/vendor-antd-BG1SvzuN.js">
|
|
24
24
|
<link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-8NDhydlF.js">
|
|
25
25
|
<link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-BB4hhpxM.js">
|
package/i18n.js
CHANGED
|
@@ -2200,24 +2200,24 @@ const i18nData = {
|
|
|
2200
2200
|
"uk": "Виберіть послідовні файли логів"
|
|
2201
2201
|
},
|
|
2202
2202
|
"ui.mergeTooLarge": {
|
|
2203
|
-
"zh": "选中的日志文件总大小超过
|
|
2204
|
-
"en": "Selected log files exceed
|
|
2205
|
-
"zh-TW": "選中的日誌檔案總大小超過
|
|
2206
|
-
"ko": "선택한 로그 파일의 총 크기가
|
|
2207
|
-
"ja": "選択したログファイルの合計サイズが
|
|
2208
|
-
"de": "Ausgewählte Log-Dateien überschreiten
|
|
2209
|
-
"es": "Los archivos de log seleccionados superan los
|
|
2210
|
-
"fr": "Les fichiers de log sélectionnés dépassent
|
|
2211
|
-
"it": "I file di log selezionati superano i
|
|
2212
|
-
"da": "Valgte logfiler overstiger
|
|
2213
|
-
"pl": "Wybrane pliki logów przekraczają
|
|
2214
|
-
"ru": "Выбранные файлы логов превышают
|
|
2215
|
-
"ar": "ملفات السجلات المحددة تتجاوز
|
|
2216
|
-
"no": "Valgte loggfiler overstiger
|
|
2217
|
-
"pt-BR": "Arquivos de log selecionados excedem
|
|
2218
|
-
"th": "ไฟล์บันทึกที่เลือกมีขนาดรวมเกิน
|
|
2219
|
-
"tr": "Seçilen log dosyaları
|
|
2220
|
-
"uk": "Вибрані файли логів перевищують
|
|
2203
|
+
"zh": "选中的日志文件总大小超过 400MB",
|
|
2204
|
+
"en": "Selected log files exceed 400MB",
|
|
2205
|
+
"zh-TW": "選中的日誌檔案總大小超過 400MB",
|
|
2206
|
+
"ko": "선택한 로그 파일의 총 크기가 400MB를 초과합니다",
|
|
2207
|
+
"ja": "選択したログファイルの合計サイズが 400MB を超えています",
|
|
2208
|
+
"de": "Ausgewählte Log-Dateien überschreiten 400MB",
|
|
2209
|
+
"es": "Los archivos de log seleccionados superan los 400MB",
|
|
2210
|
+
"fr": "Les fichiers de log sélectionnés dépassent 400 Mo",
|
|
2211
|
+
"it": "I file di log selezionati superano i 400MB",
|
|
2212
|
+
"da": "Valgte logfiler overstiger 400MB",
|
|
2213
|
+
"pl": "Wybrane pliki logów przekraczają 400MB",
|
|
2214
|
+
"ru": "Выбранные файлы логов превышают 400МБ",
|
|
2215
|
+
"ar": "ملفات السجلات المحددة تتجاوز 400 ميجابايت",
|
|
2216
|
+
"no": "Valgte loggfiler overstiger 400MB",
|
|
2217
|
+
"pt-BR": "Arquivos de log selecionados excedem 400MB",
|
|
2218
|
+
"th": "ไฟล์บันทึกที่เลือกมีขนาดรวมเกิน 400MB",
|
|
2219
|
+
"tr": "Seçilen log dosyaları 400MB'ı aşıyor",
|
|
2220
|
+
"uk": "Вибрані файли логів перевищують 400МБ"
|
|
2221
2221
|
},
|
|
2222
2222
|
"ui.mergeSuccess": {
|
|
2223
2223
|
"zh": "日志合并成功",
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync, statSync, lstatSync, mkdirSync, renameSync, unlinkSync,
|
|
3
|
+
writeFileSync, readFileSync, readdirSync, rmSync,
|
|
4
|
+
} from 'node:fs';
|
|
5
|
+
import { join, basename } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { validateZipEntries } from './zip-safety.js';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const AdmZip = require('adm-zip');
|
|
13
|
+
|
|
14
|
+
const CACHE_ROOT_NAME = 'ccv-extract';
|
|
15
|
+
const RENAME_RETRY = 3;
|
|
16
|
+
const RENAME_DELAY_MS = 50;
|
|
17
|
+
const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
|
|
18
|
+
// 与 lib/log-management.js 的 MAX_MERGE_SIZE 对齐:merge 上限 400MB,归档 / 读回 zip
|
|
19
|
+
// 必须容纳这一规模,否则自家产物会被 zip-safety 默认 50MB 阈值拦住造成永久不可读。
|
|
20
|
+
const ARCHIVE_MAX_BYTES = 400 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
function syncSleep(ms) {
|
|
23
|
+
// Atomics.wait 在主线程上真睡眠(不像 busy-wait 占满 CPU 或阻塞 event-loop heuristics)
|
|
24
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renameWithRetry(from, to) {
|
|
28
|
+
let lastErr;
|
|
29
|
+
for (let i = 0; i < RENAME_RETRY; i++) {
|
|
30
|
+
try { renameSync(from, to); return; }
|
|
31
|
+
catch (err) {
|
|
32
|
+
lastErr = err;
|
|
33
|
+
if (i < RENAME_RETRY - 1 && (err.code === 'EBUSY' || err.code === 'EPERM' || err.code === 'EACCES')) {
|
|
34
|
+
syncSleep(RENAME_DELAY_MS);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw lastErr;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getExtractCacheRoot() {
|
|
44
|
+
return join(tmpdir(), CACHE_ROOT_NAME);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pathHash(absPath) {
|
|
48
|
+
return createHash('sha1').update(absPath).digest('hex').slice(0, 16);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function archiveJsonl(absJsonlPath) {
|
|
52
|
+
if (!absJsonlPath.endsWith('.jsonl')) return { ok: false, error: 'Not a .jsonl file' };
|
|
53
|
+
if (!existsSync(absJsonlPath)) return { ok: false, error: 'Source file not found' };
|
|
54
|
+
|
|
55
|
+
// 拒绝 symlink 源:避免归档目标外文件并删除链接造成信息错乱
|
|
56
|
+
try {
|
|
57
|
+
const lst = lstatSync(absJsonlPath);
|
|
58
|
+
if (!lst.isFile()) return { ok: false, error: 'Source is not a regular file' };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return { ok: false, error: err.message };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const zipPath = absJsonlPath + '.zip';
|
|
64
|
+
if (existsSync(zipPath)) {
|
|
65
|
+
return { ok: false, skipped: 'target-exists', error: 'Target .zip already exists' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const tmpPath = `${zipPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
69
|
+
try {
|
|
70
|
+
const zip = new AdmZip();
|
|
71
|
+
zip.addLocalFile(absJsonlPath);
|
|
72
|
+
for (const e of zip.getEntries()) {
|
|
73
|
+
if (e.header) e.header.flags = (e.header.flags || 0) | 0x800;
|
|
74
|
+
}
|
|
75
|
+
zip.writeZip(tmpPath);
|
|
76
|
+
|
|
77
|
+
if (!existsSync(tmpPath) || statSync(tmpPath).size === 0) {
|
|
78
|
+
throw new Error('Generated zip is empty');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
renameWithRetry(tmpPath, zipPath);
|
|
82
|
+
try { unlinkSync(absJsonlPath); }
|
|
83
|
+
catch (err) {
|
|
84
|
+
// 罕见:zip 已 rename 成功但原 .jsonl 删除失败(AV 扫描 / Windows 句柄占用 / 权限)。
|
|
85
|
+
// 此时两份共存,下次列表 regex 会同时显示,再次归档会撞"Target exists"死锁。
|
|
86
|
+
// 回滚策略:删掉刚 rename 出来的 zip,原文件原位保留,调用方收到 failed → 用户可重试。
|
|
87
|
+
try { unlinkSync(zipPath); } catch { /* zip rollback 失败也容忍:极端边界 */ }
|
|
88
|
+
return { ok: false, error: `archived but failed to delete source: ${err.message}` };
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, zipPath };
|
|
91
|
+
} catch (err) {
|
|
92
|
+
try { if (existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
93
|
+
return { ok: false, error: err.message };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function resolveJsonlPath(absPath) {
|
|
98
|
+
if (typeof absPath !== 'string') return absPath;
|
|
99
|
+
if (absPath.endsWith('.jsonl') && existsSync(absPath)) return absPath;
|
|
100
|
+
|
|
101
|
+
let zipPath = null;
|
|
102
|
+
if (absPath.endsWith('.jsonl.zip')) zipPath = absPath;
|
|
103
|
+
else if (absPath.endsWith('.jsonl')) {
|
|
104
|
+
const candidate = absPath + '.zip';
|
|
105
|
+
if (existsSync(candidate)) zipPath = candidate;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!zipPath || !existsSync(zipPath)) return absPath;
|
|
109
|
+
return extractZipSync(zipPath);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function extractZipSync(zipPath) {
|
|
113
|
+
const cacheKey = pathHash(zipPath);
|
|
114
|
+
const cacheDir = join(getExtractCacheRoot(), cacheKey);
|
|
115
|
+
const targetName = basename(zipPath, '.zip');
|
|
116
|
+
const tmpFile = join(cacheDir, targetName);
|
|
117
|
+
const sidecar = join(cacheDir, '.meta.json');
|
|
118
|
+
|
|
119
|
+
const zipStat = statSync(zipPath);
|
|
120
|
+
const expectedMeta = { srcMtimeMs: zipStat.mtimeMs, srcSize: zipStat.size };
|
|
121
|
+
|
|
122
|
+
if (existsSync(tmpFile) && existsSync(sidecar)) {
|
|
123
|
+
try {
|
|
124
|
+
const meta = JSON.parse(readFileSync(sidecar, 'utf-8'));
|
|
125
|
+
if (meta.srcMtimeMs === expectedMeta.srcMtimeMs && meta.srcSize === expectedMeta.srcSize) {
|
|
126
|
+
return tmpFile;
|
|
127
|
+
}
|
|
128
|
+
} catch { /* fall through to re-extract */ }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
132
|
+
const zip = new AdmZip(zipPath);
|
|
133
|
+
const entries = zip.getEntries();
|
|
134
|
+
|
|
135
|
+
validateZipEntries(entries, cacheDir, {
|
|
136
|
+
maxEntries: 5,
|
|
137
|
+
requireExtension: '.jsonl',
|
|
138
|
+
maxPerFile: ARCHIVE_MAX_BYTES,
|
|
139
|
+
maxTotal: ARCHIVE_MAX_BYTES,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const fileEntry = entries.find(e => !e.isDirectory && e.entryName.toLowerCase().endsWith('.jsonl'));
|
|
143
|
+
if (!fileEntry) {
|
|
144
|
+
throw Object.assign(new Error('No .jsonl entry found in zip'), { code: 'ZIP_UNSAFE' });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const partial = `${tmpFile}.partial.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
148
|
+
try {
|
|
149
|
+
writeFileSync(partial, fileEntry.getData());
|
|
150
|
+
renameWithRetry(partial, tmpFile);
|
|
151
|
+
writeFileSync(sidecar, JSON.stringify(expectedMeta));
|
|
152
|
+
} catch (err) {
|
|
153
|
+
try { if (existsSync(partial)) unlinkSync(partial); } catch { /* ignore */ }
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return tmpFile;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function cleanupExtractCache() {
|
|
161
|
+
try {
|
|
162
|
+
const root = getExtractCacheRoot();
|
|
163
|
+
if (!existsSync(root)) return;
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
for (const ent of readdirSync(root, { withFileTypes: true })) {
|
|
166
|
+
if (!ent.isDirectory()) continue;
|
|
167
|
+
const subDir = join(root, ent.name);
|
|
168
|
+
try {
|
|
169
|
+
const stat = statSync(subDir);
|
|
170
|
+
// 用 mtimeMs(解压完成时间)而非 atimeMs:macOS / Linux 默认 noatime/relatime
|
|
171
|
+
// 下 atime 几乎不更新,会让 cache 永不过期或被错误提前回收。mtime 由 mkdirSync /
|
|
172
|
+
// 重新解压 rename 触发,能准确反映"最近一次解压时间"。
|
|
173
|
+
if (now - stat.mtimeMs > CACHE_TTL_MS) {
|
|
174
|
+
rmSync(subDir, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
} catch { /* tolerate one bad entry */ }
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.warn('[ccv-extract] cleanup failed:', err.message);
|
|
180
|
+
}
|
|
181
|
+
}
|
package/lib/log-management.js
CHANGED
|
@@ -3,6 +3,7 @@ import { renameSyncWithRetry } from './file-api.js';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { reconstructEntries } from './delta-reconstructor.js';
|
|
5
5
|
import { streamReconstructedEntries } from './log-stream.js';
|
|
6
|
+
import { archiveJsonl, resolveJsonlPath } from './jsonl-archive.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Validate that a resolved file path is contained within logDir.
|
|
@@ -28,6 +29,10 @@ export function validateLogPath(logDir, file) {
|
|
|
28
29
|
return realPath;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
function isLogFileName(name) {
|
|
33
|
+
return name.endsWith('.jsonl') || name.endsWith('.jsonl.zip');
|
|
34
|
+
}
|
|
35
|
+
|
|
31
36
|
/**
|
|
32
37
|
* List local log files grouped by project.
|
|
33
38
|
* @param {string} logDir - base log directory
|
|
@@ -43,7 +48,7 @@ export function listLocalLogs(logDir, currentProjectName) {
|
|
|
43
48
|
const project = entry.name;
|
|
44
49
|
const projectDir = join(logDir, project);
|
|
45
50
|
const files = readdirSync(projectDir)
|
|
46
|
-
.filter(
|
|
51
|
+
.filter(isLogFileName)
|
|
47
52
|
.sort()
|
|
48
53
|
.reverse();
|
|
49
54
|
// 从项目统计缓存中读取 per-file 数据,避免逐文件扫描
|
|
@@ -55,15 +60,18 @@ export function listLocalLogs(logDir, currentProjectName) {
|
|
|
55
60
|
}
|
|
56
61
|
} catch { }
|
|
57
62
|
for (const f of files) {
|
|
58
|
-
const match = f.match(/^(.+?)_(\d{8}_\d{6})\.jsonl
|
|
63
|
+
const match = f.match(/^(.+?)_(\d{8}_\d{6})\.jsonl(\.zip)?$/);
|
|
59
64
|
if (!match) continue;
|
|
60
65
|
const ts = match[2];
|
|
66
|
+
const archived = !!match[3];
|
|
61
67
|
const filePath = join(projectDir, f);
|
|
62
68
|
const size = statSync(filePath).size;
|
|
63
69
|
if (size === 0) continue; // 跳过空文件
|
|
64
|
-
|
|
70
|
+
// 归档前的统计缓存 key 是 `.jsonl`;归档后切到 `.jsonl.zip`;两种都尝试
|
|
71
|
+
const stats = statsFiles?.[f] || (archived ? statsFiles?.[f.slice(0, -4)] : null);
|
|
72
|
+
const turns = stats?.summary?.sessionCount || 0;
|
|
65
73
|
if (!grouped[project]) grouped[project] = [];
|
|
66
|
-
grouped[project].push({ file: `${project}/${f}`, timestamp: ts, size, turns, preview:
|
|
74
|
+
grouped[project].push({ file: `${project}/${f}`, timestamp: ts, size, turns, preview: stats?.preview || [], archived });
|
|
67
75
|
}
|
|
68
76
|
}
|
|
69
77
|
}
|
|
@@ -78,7 +86,7 @@ export function listLocalLogs(logDir, currentProjectName) {
|
|
|
78
86
|
*/
|
|
79
87
|
export function readLocalLog(logDir, file) {
|
|
80
88
|
validateLogPath(logDir, file);
|
|
81
|
-
const filePath = join(logDir, file);
|
|
89
|
+
const filePath = resolveJsonlPath(join(logDir, file));
|
|
82
90
|
const content = readFileSync(filePath, 'utf-8');
|
|
83
91
|
const parsed = content.split('\n---\n').filter(line => line.trim()).map(entry => {
|
|
84
92
|
try { return JSON.parse(entry); } catch { return null; }
|
|
@@ -101,7 +109,7 @@ export function readLocalLog(logDir, file) {
|
|
|
101
109
|
export function deleteLogFiles(logDir, files) {
|
|
102
110
|
const results = [];
|
|
103
111
|
for (const file of files) {
|
|
104
|
-
if (!file || file.includes('..') || !file
|
|
112
|
+
if (!file || file.includes('..') || !isLogFileName(file)) {
|
|
105
113
|
results.push({ file, error: 'Invalid file name' });
|
|
106
114
|
continue;
|
|
107
115
|
}
|
|
@@ -138,6 +146,16 @@ export function mergeLogFiles(logDir, files) {
|
|
|
138
146
|
err.code = 'INVALID_INPUT';
|
|
139
147
|
throw err;
|
|
140
148
|
}
|
|
149
|
+
// 拒绝归档文件参与合并:mergeLogFiles 会以 files[0] 路径写入 plain jsonl 内容,若该路径
|
|
150
|
+
// 是 .jsonl.zip 会把 zip 文件覆写成裸文本破坏归档;且合并产物语义上应该是可继续追加的
|
|
151
|
+
// 活动文件,与"归档=只读快照"语义冲突。前端 UI 已 disabled,此处后端兜底。
|
|
152
|
+
for (const f of files) {
|
|
153
|
+
if (typeof f === 'string' && f.endsWith('.jsonl.zip')) {
|
|
154
|
+
const err = new Error('Cannot merge archived (.jsonl.zip) files');
|
|
155
|
+
err.code = 'INVALID_INPUT';
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
141
159
|
// 校验所有文件属于同一 project
|
|
142
160
|
// 兼容 Win backslash:files 内部可能是 `project\log.json`,按两种 sep 都切才能拿 project 段。
|
|
143
161
|
const projects = new Set(files.map(f => f.split(/[\\/]/)[0]));
|
|
@@ -159,14 +177,14 @@ export function mergeLogFiles(logDir, files) {
|
|
|
159
177
|
throw err;
|
|
160
178
|
}
|
|
161
179
|
}
|
|
162
|
-
// 校验合并后总大小不超过
|
|
163
|
-
const MAX_MERGE_SIZE =
|
|
180
|
+
// 校验合并后总大小不超过 400MB
|
|
181
|
+
const MAX_MERGE_SIZE = 400 * 1024 * 1024;
|
|
164
182
|
let totalSize = 0;
|
|
165
183
|
for (const f of files) {
|
|
166
184
|
totalSize += statSync(join(logDir, f)).size;
|
|
167
185
|
}
|
|
168
186
|
if (totalSize > MAX_MERGE_SIZE) {
|
|
169
|
-
const err = new Error(`Merged size (${(totalSize / 1024 / 1024).toFixed(1)}MB) exceeds
|
|
187
|
+
const err = new Error(`Merged size (${(totalSize / 1024 / 1024).toFixed(1)}MB) exceeds ${MAX_MERGE_SIZE / 1024 / 1024}MB limit`);
|
|
170
188
|
err.code = 'INVALID_INPUT';
|
|
171
189
|
throw err;
|
|
172
190
|
}
|
|
@@ -198,3 +216,104 @@ export function mergeLogFiles(logDir, files) {
|
|
|
198
216
|
}
|
|
199
217
|
return targetFile;
|
|
200
218
|
}
|
|
219
|
+
|
|
220
|
+
function migrateStatsCacheKey(projectDir, projectName, oldFileName, newFileName) {
|
|
221
|
+
const statsFile = join(projectDir, `${projectName}.json`);
|
|
222
|
+
if (!existsSync(statsFile)) return;
|
|
223
|
+
try {
|
|
224
|
+
const stats = JSON.parse(readFileSync(statsFile, 'utf-8'));
|
|
225
|
+
if (stats?.files?.[oldFileName]) {
|
|
226
|
+
const entry = stats.files[oldFileName];
|
|
227
|
+
// 同步用归档后 .zip 的 size / mtime 覆写 entry,避免 stats-worker 下次扫描时
|
|
228
|
+
// 因 size/mtime 不匹配判定 cache stale 触发整文件重解析(大 jsonl 数秒 CPU)。
|
|
229
|
+
try {
|
|
230
|
+
const zipStat = statSync(join(projectDir, newFileName));
|
|
231
|
+
entry.size = zipStat.size;
|
|
232
|
+
entry.lastModified = zipStat.mtime.toISOString();
|
|
233
|
+
} catch { /* zip 不可 stat 时不更新,让 stats 自然重建 */ }
|
|
234
|
+
stats.files[newFileName] = entry;
|
|
235
|
+
delete stats.files[oldFileName];
|
|
236
|
+
writeFileSync(statsFile, JSON.stringify(stats, null, 2));
|
|
237
|
+
}
|
|
238
|
+
} catch { /* tolerant */ }
|
|
239
|
+
}
|
|
240
|
+
|
|
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
|
+
export function archiveLogFiles(logDir, files) {
|
|
249
|
+
const archived = [];
|
|
250
|
+
const skipped = [];
|
|
251
|
+
const failed = [];
|
|
252
|
+
|
|
253
|
+
// 按 project 分组以判定最新文件
|
|
254
|
+
const byProject = new Map();
|
|
255
|
+
for (const f of files) {
|
|
256
|
+
if (!f || typeof f !== 'string' || f.includes('..') || !f.endsWith('.jsonl')) {
|
|
257
|
+
failed.push({ file: f, reason: 'Invalid file name' });
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const parts = f.split(/[\\/]/);
|
|
261
|
+
if (parts.length < 2) {
|
|
262
|
+
failed.push({ file: f, reason: 'Invalid file path' });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const project = parts[0];
|
|
266
|
+
if (!byProject.has(project)) byProject.set(project, []);
|
|
267
|
+
byProject.get(project).push(f);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let realLogDir;
|
|
271
|
+
try { realLogDir = realpathSync(logDir); }
|
|
272
|
+
catch (err) { return { archived, skipped, failed: files.map(f => ({ file: f, reason: err.message })) }; }
|
|
273
|
+
|
|
274
|
+
for (const [project, projectFiles] of byProject) {
|
|
275
|
+
const projectDir = join(logDir, project);
|
|
276
|
+
let latest = null;
|
|
277
|
+
try {
|
|
278
|
+
const projectEntries = readdirSync(projectDir)
|
|
279
|
+
.filter(isLogFileName)
|
|
280
|
+
.sort()
|
|
281
|
+
.reverse();
|
|
282
|
+
latest = projectEntries[0] || null;
|
|
283
|
+
} catch { /* directory missing => downstream calls will fail */ }
|
|
284
|
+
|
|
285
|
+
for (const f of projectFiles) {
|
|
286
|
+
const fileName = f.split(/[\\/]/).slice(1).join('/');
|
|
287
|
+
if (latest && fileName === latest) {
|
|
288
|
+
skipped.push({ file: f, reason: 'latest-not-allowed' });
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const filePath = join(logDir, f);
|
|
292
|
+
let realPath;
|
|
293
|
+
try {
|
|
294
|
+
if (!existsSync(filePath)) { failed.push({ file: f, reason: 'Not found' }); continue; }
|
|
295
|
+
realPath = realpathSync(filePath);
|
|
296
|
+
if (!realPath.startsWith(realLogDir)) {
|
|
297
|
+
failed.push({ file: f, reason: 'Access denied' });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
} catch (err) {
|
|
301
|
+
failed.push({ file: f, reason: err.message });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const result = archiveJsonl(realPath);
|
|
306
|
+
if (result.ok) {
|
|
307
|
+
archived.push(f);
|
|
308
|
+
migrateStatsCacheKey(projectDir, project, fileName, fileName + '.zip');
|
|
309
|
+
} else if (result.skipped) {
|
|
310
|
+
skipped.push({ file: f, reason: result.skipped });
|
|
311
|
+
} else {
|
|
312
|
+
// archiveJsonl 内部已在 unlink 失败时回滚 zip,此处 fail 即原状态完整保留,用户可重试
|
|
313
|
+
failed.push({ file: f, reason: result.error || 'archive failed' });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { archived, skipped, failed };
|
|
319
|
+
}
|
package/lib/log-stream.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
14
14
|
import { isCheckpointEntry, isDeltaEntry, reconstructSegment } from './delta-reconstructor.js';
|
|
15
|
+
import { resolveJsonlPath } from './jsonl-archive.js';
|
|
15
16
|
|
|
16
17
|
const READ_CHUNK_SIZE = 1024 * 1024; // 1MB
|
|
17
18
|
const SEPARATOR = '\n---\n';
|
|
@@ -21,6 +22,7 @@ const SEPARATOR = '\n---\n';
|
|
|
21
22
|
* 内存 = 1MB buffer + pending。
|
|
22
23
|
*/
|
|
23
24
|
function* iterateRawEntries(filePath) {
|
|
25
|
+
filePath = resolveJsonlPath(filePath);
|
|
24
26
|
const fileSize = statSync(filePath).size;
|
|
25
27
|
if (fileSize === 0) return;
|
|
26
28
|
|
|
@@ -59,6 +61,7 @@ function* iterateRawEntries(filePath) {
|
|
|
59
61
|
* 用于 SSE load_start 的 total 字段(进度显示)。
|
|
60
62
|
*/
|
|
61
63
|
export function countLogEntries(filePath) {
|
|
64
|
+
filePath = resolveJsonlPath(filePath);
|
|
62
65
|
if (!existsSync(filePath)) return 0;
|
|
63
66
|
let count = 0;
|
|
64
67
|
for (const _ of iterateRawEntries(filePath)) { count++; }
|
|
@@ -102,6 +105,7 @@ function isSegmentBoundary(entry) {
|
|
|
102
105
|
// ============================================================================
|
|
103
106
|
|
|
104
107
|
export function streamReconstructedEntries(filePath, onSegment, opts = {}) {
|
|
108
|
+
filePath = resolveJsonlPath(filePath);
|
|
105
109
|
if (!existsSync(filePath)) return 0;
|
|
106
110
|
const stat = statSync(filePath);
|
|
107
111
|
if (stat.size === 0) return 0;
|
|
@@ -178,6 +182,7 @@ export function streamReconstructedEntries(filePath, onSegment, opts = {}) {
|
|
|
178
182
|
* @returns {Promise<{sentCount: number, totalCount: number}>}
|
|
179
183
|
*/
|
|
180
184
|
export async function streamRawEntriesAsync(filePath, onRawEntry, opts = {}) {
|
|
185
|
+
filePath = resolveJsonlPath(filePath);
|
|
181
186
|
const empty = { sentCount: 0, totalCount: 0 };
|
|
182
187
|
if (!existsSync(filePath)) { if (opts.onReady) opts.onReady({ totalCount: 0 }); return empty; }
|
|
183
188
|
const stat = statSync(filePath);
|
|
@@ -266,6 +271,7 @@ export async function streamRawEntriesAsync(filePath, onRawEntry, opts = {}) {
|
|
|
266
271
|
* @returns {{ entries: string[], hasMore: boolean, oldestTimestamp: string, count: number }}
|
|
267
272
|
*/
|
|
268
273
|
export function readPagedEntries(filePath, { before, limit }) {
|
|
274
|
+
filePath = resolveJsonlPath(filePath);
|
|
269
275
|
if (!existsSync(filePath)) return { entries: [], hasMore: false, oldestTimestamp: '', count: 0 };
|
|
270
276
|
const stat = statSync(filePath);
|
|
271
277
|
if (stat.size === 0) return { entries: [], hasMore: false, oldestTimestamp: '', count: 0 };
|
package/lib/log-watcher.js
CHANGED
|
@@ -4,6 +4,7 @@ import { buildContextWindowEvent, getContextSizeForModel } from './context-watch
|
|
|
4
4
|
import { reconstructEntries, createIncrementalReconstructor } from './delta-reconstructor.js';
|
|
5
5
|
import { countLogEntries, streamReconstructedEntries } from './log-stream.js';
|
|
6
6
|
import { enrichEntry } from './enrich-plan-input.js';
|
|
7
|
+
import { resolveJsonlPath } from './jsonl-archive.js';
|
|
7
8
|
|
|
8
9
|
// 跟踪所有被 watch 的日志文件
|
|
9
10
|
const watchedFiles = new Map();
|
|
@@ -14,6 +15,9 @@ const watchedFiles = new Map();
|
|
|
14
15
|
* @returns {Array} parsed and deduplicated entries
|
|
15
16
|
*/
|
|
16
17
|
export function readLogFile(logFile) {
|
|
18
|
+
// 透明支持归档后的 .jsonl.zip。active log(被 watchLogFile 追加写入的文件)不会被归档
|
|
19
|
+
// (archive 拒绝最新文件),watchLogFile 内部的 openSync/readSync 因此无需走 resolveJsonlPath。
|
|
20
|
+
logFile = resolveJsonlPath(logFile);
|
|
17
21
|
if (!existsSync(logFile)) {
|
|
18
22
|
return [];
|
|
19
23
|
}
|
package/lib/stats-worker.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { parentPort } from 'node:worker_threads';
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
4
4
|
import { join, basename } from 'node:path';
|
|
5
|
+
import { resolveJsonlPath } from './jsonl-archive.js';
|
|
5
6
|
|
|
6
7
|
// 统计 schema 版本号,新增统计字段时递增,强制旧缓存失效重新解析
|
|
7
8
|
const STATS_VERSION = 8;
|
|
@@ -98,7 +99,8 @@ function parseJsonlFile(filePath) {
|
|
|
98
99
|
let lastCollectedSig = ''; // 上次收集的 prompt 签名,用于去重同轮重复请求
|
|
99
100
|
|
|
100
101
|
try {
|
|
101
|
-
const
|
|
102
|
+
const realPath = resolveJsonlPath(filePath);
|
|
103
|
+
const content = readFileSync(realPath, 'utf-8');
|
|
102
104
|
if (!content.trim()) return { models, summary: { requestCount: 0, sessionCount: 0, turnCount: 0, input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, preview: [] };
|
|
103
105
|
|
|
104
106
|
const entries = content.split('\n---\n').filter(p => p.trim());
|
|
@@ -234,7 +236,7 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
|
|
|
234
236
|
let jsonlFiles;
|
|
235
237
|
try {
|
|
236
238
|
jsonlFiles = readdirSync(projectDir)
|
|
237
|
-
.filter(f => f.endsWith('.jsonl') && !f.endsWith('_temp.jsonl'))
|
|
239
|
+
.filter(f => (f.endsWith('.jsonl') || f.endsWith('.jsonl.zip')) && !f.endsWith('_temp.jsonl'))
|
|
238
240
|
.sort();
|
|
239
241
|
} catch {
|
|
240
242
|
return;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { resolve, sep, posix } from 'node:path';
|
|
2
|
+
|
|
3
|
+
const MAX_PER_FILE_DEFAULT = 50 * 1024 * 1024;
|
|
4
|
+
const MAX_TOTAL_DEFAULT = 200 * 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
function isSymlinkEntry(entry) {
|
|
7
|
+
const unixMode = (entry.attr >>> 16) & 0xffff;
|
|
8
|
+
return (unixMode & 0o170000) === 0o120000;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function toPosix(name) {
|
|
12
|
+
return name.split('\\').join('/');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isSafeEntryName(name) {
|
|
16
|
+
if (!name || typeof name !== 'string') return false;
|
|
17
|
+
if (name.includes('\x00')) return false;
|
|
18
|
+
const slashed = toPosix(name);
|
|
19
|
+
if (slashed.startsWith('/')) return false;
|
|
20
|
+
if (/^[A-Za-z]:/.test(slashed)) return false;
|
|
21
|
+
const norm = posix.normalize(slashed);
|
|
22
|
+
if (norm === '..' || norm.startsWith('../') || norm.includes('/../')) return false;
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isWithinTargetDir(entryName, targetDir) {
|
|
27
|
+
const target = resolve(targetDir);
|
|
28
|
+
const dest = resolve(target, toPosix(entryName));
|
|
29
|
+
return dest === target || dest.startsWith(target + sep);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateZipEntries(entries, targetDir, opts = {}) {
|
|
33
|
+
const {
|
|
34
|
+
maxEntries = Infinity,
|
|
35
|
+
maxPerFile = MAX_PER_FILE_DEFAULT,
|
|
36
|
+
maxTotal = MAX_TOTAL_DEFAULT,
|
|
37
|
+
requireExtension = null,
|
|
38
|
+
} = opts;
|
|
39
|
+
let fileCount = 0;
|
|
40
|
+
let totalSize = 0;
|
|
41
|
+
for (const e of entries) {
|
|
42
|
+
if (e.isDirectory) continue;
|
|
43
|
+
fileCount++;
|
|
44
|
+
if (fileCount > maxEntries) {
|
|
45
|
+
throw Object.assign(new Error('Too many entries in zip'), { code: 'ZIP_TOO_MANY' });
|
|
46
|
+
}
|
|
47
|
+
if (isSymlinkEntry(e)) {
|
|
48
|
+
throw Object.assign(new Error('Symlinks not allowed in zip'), { code: 'ZIP_UNSAFE' });
|
|
49
|
+
}
|
|
50
|
+
if (!isSafeEntryName(e.entryName)) {
|
|
51
|
+
throw Object.assign(new Error(`Unsafe zip entry name: ${e.entryName}`), { code: 'ZIP_UNSAFE' });
|
|
52
|
+
}
|
|
53
|
+
if (!isWithinTargetDir(e.entryName, targetDir)) {
|
|
54
|
+
throw Object.assign(new Error(`Zip entry escapes target dir: ${e.entryName}`), { code: 'ZIP_UNSAFE' });
|
|
55
|
+
}
|
|
56
|
+
if (requireExtension && !e.entryName.toLowerCase().endsWith(requireExtension.toLowerCase())) {
|
|
57
|
+
throw Object.assign(new Error(`Disallowed extension: ${e.entryName}`), { code: 'ZIP_UNSAFE' });
|
|
58
|
+
}
|
|
59
|
+
const sizeRaw = e.header?.size || 0;
|
|
60
|
+
if (sizeRaw > maxPerFile) {
|
|
61
|
+
throw Object.assign(new Error('File too large in archive'), { code: 'ZIP_BOMB' });
|
|
62
|
+
}
|
|
63
|
+
totalSize += sizeRaw;
|
|
64
|
+
if (totalSize > maxTotal) {
|
|
65
|
+
throw Object.assign(new Error('Archive expands too large'), { code: 'ZIP_BOMB' });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|