cc-viewer 1.6.348 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/cli.js +116 -37
- package/dist/assets/App-DTkacfRW.js +2 -0
- package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-C5Q6uUyH.js} +1 -1
- package/dist/assets/Mobile-MoBp9c8k.js +1 -0
- package/dist/assets/{_baseUniq-CG81mgN_.js → _baseUniq-D9_E5ZDp.js} +1 -1
- package/dist/assets/{arc-C161J4f2.js → arc-D4iFrlhb.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BmbDMdn9.js → architectureDiagram-Q4EWVU46-f1neo7hg.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DXpMvdxd.js → blockDiagram-DXYQGD6D-DWi9e9tG.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-8Mypg0IU.js → c4Diagram-AHTNJAMY-DAdj49A2.js} +1 -1
- package/dist/assets/{channel-B2khdmvc.js → channel-B3VthT0m.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-I61r1g6z.js → chunk-4BX2VUAB-BrPcAHV3.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-VFPlRE5E.js → chunk-4TB4RGXK-Bvopc1V9.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DvCamg_6.js → chunk-55IACEB6-Juw-7Z3i.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-Dj4Q8RwR.js → chunk-EDXVE4YY-DVYP9tpB.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-_bgQuuJh.js → chunk-FMBD7UC4-VQ7VOXl3.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-3to38LyN.js → chunk-OYMX7WX6-F8Rj4ZUZ.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Zl26wKKz.js → chunk-QZHKN3VN-Dn7K3yi9.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-RLYPrlh0.js → chunk-YZCP3GAM-UjO-KTsn.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +1 -0
- package/dist/assets/clone-BQtvy9Ae.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-CsYOjJ0V.js → cose-bilkent-S5V4N54A-BDhNj8rq.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-C3YXXaFU.js → dagre-KV5264BT-BzdcDuyt.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DUCOg1ld.js → diagram-5BDNPKRD-DP_SX65N.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-BtlLl_zX.js → diagram-G4DWMVQ6-B-pBFw8Q.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-1Qp9xfOk.js → diagram-MMDJMWI5-6XSfN1eN.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Cj7VMp3L.js → diagram-TYMM5635-DJvGxvpE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CVZVn541.js → erDiagram-SMLLAGMA-CpqdpGYj.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-CiU9Fkkv.js → flowDiagram-DWJPFMVM-C9m7wjwI.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-aRaG97ID.js → ganttDiagram-T4ZO3ILL-CU0XOfY3.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Cr2LJlvX.js → gitGraphDiagram-UUTBAWPF-ipyGULnP.js} +1 -1
- package/dist/assets/{graph-B59lDyMn.js → graph-BR2KmES9.js} +1 -1
- package/dist/assets/{index-C1ptnCpq.js → index--wgRW_OB.js} +1 -1
- package/dist/assets/index-Ac8vnV3v.js +2 -0
- package/dist/assets/{index-CRAWA8PC.js → index-C-be29ey.js} +1 -1
- package/dist/assets/{index-CSM1Kw8l.js → index-C16fiVhv.js} +1 -1
- package/dist/assets/{index-Ddk-kg22.js → index-D5YruOKD.js} +1 -1
- package/dist/assets/{index-BnhNIjbj.js → index-D5tSeMU7.js} +1 -1
- package/dist/assets/{index-vFEQq-NB.js → index-DuZQ6FqR.js} +1 -1
- package/dist/assets/{index-CO2hZ1s4.js → index-OWzSX2T8.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-CnO22bw8.js → infoDiagram-42DDH7IO-BQWQjfFX.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-ByNzpScH.js → ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-yaVaCWPq.js → journeyDiagram-VCZTEJTY-CMbB_4G9.js} +1 -1
- package/dist/assets/{jszip.min-BdVFP3OW.js → jszip.min-B6k2t9Ch.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-RIPR_RQ3.js → kanban-definition-6JOO6SKY-CImdlKN3.js} +1 -1
- package/dist/assets/{layout-0rzel0v_.js → layout-DQl4aw09.js} +1 -1
- package/dist/assets/{linear-sktN0tB6.js → linear-CLD3aPlK.js} +1 -1
- package/dist/assets/mermaid.core-CN35dvKJ.js +7 -0
- package/dist/assets/{min-rspGOkz_.js → min-_D86henJ.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-BsVzZzTz.js → mindmap-definition-QFDTVHPH-2JVcu1nk.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-CsoWsw4f.js → pieDiagram-DEJITSTG-Dik-m5u7.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-CTMKuCAI.js → quadrantDiagram-34T5L4WZ-Bm5l40Jq.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-C67256te.js → requirementDiagram-MS252O5E-Ck9w5rTj.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-X8Y7KnW0.js → sankeyDiagram-XADWPNL6-BbGVCFEf.js} +1 -1
- package/dist/assets/seqResourceLoaders-Cd_TAXTb.js +2 -0
- package/dist/assets/{seqResourceLoaders-B5Zy_CC_.css → seqResourceLoaders-CxuD1CYl.css} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-Cils0TAp.js → sequenceDiagram-FGHM5R23-vjvEdksk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DFnohvpG.js → stateDiagram-FHFEXIEX-DTqMoiB2.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BTX_4o81.js → stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-DrIYFrzZ.js → timeline-definition-GMOUNBTQ-Cw8O-52d.js} +1 -1
- package/dist/assets/{vendor-antd-D5HmSDSg.js → vendor-antd-DI7JL-mE.js} +1 -1
- package/dist/assets/{vendor-codemirror-C5yuj7ze.js → vendor-codemirror-B9c49dtM.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-BcEooTvb.js → vendor-mdxeditor-B6cpBtIE.js} +2 -2
- package/dist/assets/{vendor-qrcode-d9LCn0-9.js → vendor-qrcode-Dn90-s-u.js} +1 -1
- package/dist/assets/{vendor-virtuoso-K9m4MN6a.js → vendor-virtuoso-CpdSKD_6.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-lRqBYTr9.js → vennDiagram-DHZGUBPP-DCuqymb7.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-e8YfxpOe.js → wardley-RL74JXVD-BJ20wJUU.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Bxrg774h.js → wardleyDiagram-NUSXRM2D-Vz6US3qt.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-DWhZqhq8.js → xychartDiagram-5P7HB3ND-CzkLKrmi.js} +1 -1
- package/dist/index.html +4 -4
- package/findcc.js +4 -3
- package/package.json +2 -1
- package/server/i18n.js +21 -141
- package/server/interceptor.js +211 -403
- package/server/lib/async-write-queue.js +12 -1
- package/server/lib/delta-reconstructor.js +3 -3
- package/server/lib/error-report.js +45 -0
- package/server/lib/im-log-watcher.js +68 -8
- package/server/lib/interceptor-core.js +19 -174
- package/server/lib/log-management.js +214 -247
- package/server/lib/log-stream.js +59 -108
- package/server/lib/log-watcher.js +34 -272
- package/server/lib/log-zip.js +148 -0
- package/server/lib/session-boundary.js +266 -0
- package/server/lib/session-pin-store.js +10 -19
- package/server/lib/stats-worker.js +138 -267
- package/server/lib/user-prompt-extract.js +250 -0
- package/server/lib/v2/adapter.js +1059 -0
- package/server/lib/v2/blob-store.js +44 -0
- package/server/lib/v2/conversation-store.js +217 -0
- package/server/lib/v2/convert-manager.js +79 -0
- package/server/lib/v2/convert-worker.js +42 -0
- package/server/lib/v2/convert.js +409 -0
- package/server/lib/v2/identity.js +204 -0
- package/server/lib/v2/journal.js +62 -0
- package/server/lib/v2/layout.js +167 -0
- package/server/lib/v2/live-feed.js +451 -0
- package/server/lib/v2/migrate-prompt.js +54 -0
- package/server/lib/v2/replay.js +191 -0
- package/server/lib/v2/session-select.js +222 -0
- package/server/lib/v2/singleflight.js +104 -0
- package/server/lib/v2/v2-writer.js +513 -0
- package/server/lib/v2/verify.js +233 -0
- package/server/routes/events.js +30 -130
- package/server/routes/files-fs.js +4 -2
- package/server/routes/im.js +11 -2
- package/server/routes/logs.js +194 -152
- package/server/routes/preferences.js +0 -3
- package/server/routes/project-meta.js +2 -3
- package/server/routes/session-pin.js +7 -8
- package/server/routes/workspaces.js +44 -9
- package/server/server.js +47 -65
- package/server/workspace-registry.js +24 -4
- package/dist/assets/App-VTo4NVdo.js +0 -2
- package/dist/assets/Mobile-CcmYvMm_.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
- package/dist/assets/clone-md-Mc1-T.js +0 -1
- package/dist/assets/index-DCo650PG.js +0 -2
- package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
- package/dist/assets/seqResourceLoaders-BVozq68i.js +0 -2
- package/server/lib/instance-registry.js +0 -57
- package/server/lib/jsonl-archive.js +0 -180
- package/server/lib/teammate-detect.js +0 -32
- package/server/lib/zip-safety.js +0 -68
|
@@ -1,14 +1,50 @@
|
|
|
1
|
-
import { existsSync, realpathSync,
|
|
2
|
-
import { readFile,
|
|
3
|
-
import { randomBytes } from 'node:crypto';
|
|
4
|
-
import { renameSyncWithRetry } from './file-api.js';
|
|
1
|
+
import { existsSync, realpathSync, readdirSync, readFileSync, statSync, mkdirSync, renameSync } from 'node:fs';
|
|
2
|
+
import { readFile, stat, readdir } from 'node:fs/promises';
|
|
5
3
|
import { join, sep, dirname, basename } from 'node:path';
|
|
6
4
|
import { reconstructEntries } from './delta-reconstructor.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
5
|
+
import { sanitizePathComponent } from './v2/layout.js';
|
|
6
|
+
import { listV2Sessions } from './v2/adapter.js';
|
|
7
|
+
|
|
8
|
+
// wire-v2 S5 addressing (spec §12): 'v2:<project>/<session_id>' in every
|
|
9
|
+
// existing ?file= parameter slot. Components must survive the same whitelist
|
|
10
|
+
// the writer used to create them — anything else (.., separators, empty) is
|
|
11
|
+
// rejected before a single path join happens.
|
|
12
|
+
export const V2_REF_RE = /^v2:([^/]+)\/([^/]+)$/;
|
|
13
|
+
|
|
14
|
+
/** Parse a v2 addressing string → { project, sessionId } or null. */
|
|
15
|
+
export function parseV2Ref(file) {
|
|
16
|
+
const m = typeof file === 'string' ? file.match(V2_REF_RE) : null;
|
|
17
|
+
if (!m) return null;
|
|
18
|
+
if (m[1] !== sanitizePathComponent(m[1]) || m[2] !== sanitizePathComponent(m[2])) return null;
|
|
19
|
+
return { project: m[1], sessionId: m[2] };
|
|
20
|
+
}
|
|
10
21
|
|
|
11
22
|
export function validateLogPath(logDir, file) {
|
|
23
|
+
// v2 branch: strip the prefix, then the realpath must land inside
|
|
24
|
+
// LOG_DIR/<project>/sessions/<session_id>/ (spec §12). Returns the absolute
|
|
25
|
+
// session DIRECTORY — log-stream's generators dispatch on it.
|
|
26
|
+
if (typeof file === 'string' && file.startsWith('v2:')) {
|
|
27
|
+
const ref = parseV2Ref(file);
|
|
28
|
+
if (!ref) {
|
|
29
|
+
const err = new Error('Invalid v2 log reference');
|
|
30
|
+
err.code = 'ACCESS_DENIED';
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
const dir = join(logDir, ref.project, 'sessions', ref.sessionId);
|
|
34
|
+
if (!existsSync(join(dir, 'journal.jsonl'))) {
|
|
35
|
+
const err = new Error('File not found');
|
|
36
|
+
err.code = 'NOT_FOUND';
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
const realPath = realpathSync(dir);
|
|
40
|
+
const realLogDir = realpathSync(logDir);
|
|
41
|
+
if (!realPath.startsWith(realLogDir + sep)) {
|
|
42
|
+
const err = new Error('Access denied');
|
|
43
|
+
err.code = 'ACCESS_DENIED';
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
return realPath;
|
|
47
|
+
}
|
|
12
48
|
const filePath = join(logDir, file);
|
|
13
49
|
if (!existsSync(filePath)) {
|
|
14
50
|
const err = new Error('File not found');
|
|
@@ -25,24 +61,76 @@ export function validateLogPath(logDir, file) {
|
|
|
25
61
|
return realPath;
|
|
26
62
|
}
|
|
27
63
|
|
|
28
|
-
function isLogFileName(name) {
|
|
29
|
-
return name.endsWith('.jsonl')
|
|
64
|
+
export function isLogFileName(name) {
|
|
65
|
+
return name.endsWith('.jsonl');
|
|
30
66
|
}
|
|
31
67
|
|
|
32
|
-
// 解析日志文件名里的时间戳 `YYYYMMDD_HHMMSS`(带不带 `<pid>__`
|
|
68
|
+
// 解析日志文件名里的时间戳 `YYYYMMDD_HHMMSS`(带不带 `<pid>__` 前缀都适用)。
|
|
33
69
|
// 用于「按时间排序 / 判最新」——文件名整串排序会把 `<pid>__` 前缀('1' < 'c')的最新文件排到最底,
|
|
34
|
-
// 必须按时间戳排。无法解析时返回 ''(排到最后)。
|
|
35
|
-
function parseLogTs(name) {
|
|
36
|
-
const m = name.match(/_(\d{8}_\d{6})\.jsonl
|
|
70
|
+
// 必须按时间戳排。无法解析时返回 ''(排到最后)。v2 convert 消费,防漂移。
|
|
71
|
+
export function parseLogTs(name) {
|
|
72
|
+
const m = name.match(/_(\d{8}_\d{6})\.jsonl$/);
|
|
37
73
|
return m ? m[1] : '';
|
|
38
74
|
}
|
|
39
75
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
76
|
+
// meta.startTs (ISO) → the v1 list's compact local-time stamp 'YYYYMMDD_HHMMSS'
|
|
77
|
+
// so formatTimestamp / the desc sort work on v2 items unchanged.
|
|
78
|
+
function compactLocalTs(iso) {
|
|
79
|
+
const d = new Date(iso);
|
|
80
|
+
if (isNaN(d.getTime())) return '';
|
|
81
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
82
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* wire-v2: the log list — one item per session dir,
|
|
87
|
+
* addressed as `v2:<project>/<sid>` (spec §12), same grouped output shape so
|
|
88
|
+
* the frontend log table renders it unchanged. Teammate sessions
|
|
89
|
+
* (meta.leader present) are NOT listed: the adapter re-joins them into their
|
|
90
|
+
* leader's stream, so a separate row would double-show that traffic.
|
|
91
|
+
*/
|
|
92
|
+
export function listV2Logs(logDir, currentProjectName) {
|
|
93
|
+
const grouped = {};
|
|
94
|
+
if (!existsSync(logDir)) return { ...grouped, _currentProject: currentProjectName || '' };
|
|
95
|
+
for (const entry of readdirSync(logDir, { withFileTypes: true })) {
|
|
96
|
+
if (!entry.isDirectory()) continue;
|
|
97
|
+
const project = entry.name;
|
|
98
|
+
try {
|
|
99
|
+
for (const s of listV2Sessions(join(logDir, project))) {
|
|
100
|
+
if (s.leader) continue;
|
|
101
|
+
if (s.size === 0) continue;
|
|
102
|
+
if (!grouped[project]) grouped[project] = [];
|
|
103
|
+
grouped[project].push({
|
|
104
|
+
file: `v2:${project}/${s.sid}`,
|
|
105
|
+
kind: 'v2',
|
|
106
|
+
timestamp: compactLocalTs(s.startTs),
|
|
107
|
+
size: s.size,
|
|
108
|
+
turns: s.turns,
|
|
109
|
+
preview: s.preview || [],
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (grouped[project]) {
|
|
113
|
+
grouped[project].sort((a, b) =>
|
|
114
|
+
b.timestamp.localeCompare(a.timestamp) || b.file.localeCompare(a.file));
|
|
115
|
+
}
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error(`[CC Viewer] listV2Logs: skipping unreadable project ${project}:`, err?.message || err);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { ...grouped, _currentProject: currentProjectName || '' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 1.7.0 v1 view: list legacy v1 `.jsonl` files, grouped per project — same row
|
|
126
|
+
* shape as listV2Logs so LogTable renders both views unchanged. Every
|
|
127
|
+
* timestamped `.jsonl` is listed, INCLUDING legacy `<pid>__`-prefixed ones
|
|
128
|
+
* (the instance concept is gone; filenames are shown as-is). `_temp` and other
|
|
129
|
+
* non-`_YYYYMMDD_HHMMSS.jsonl` names are excluded by the timestamp regex.
|
|
130
|
+
* turns/preview come from a pre-1.7 `<project>.json` stats file when one still
|
|
131
|
+
* carries per-file entries (v9 stats no longer track v1 files) — best-effort.
|
|
132
|
+
*/
|
|
133
|
+
export async function listLocalLogs(logDir, currentProjectName) {
|
|
46
134
|
const grouped = {};
|
|
47
135
|
if (!existsSync(logDir)) return { ...grouped, _currentProject: currentProjectName || '' };
|
|
48
136
|
|
|
@@ -51,9 +139,9 @@ export async function listLocalLogs(logDir, currentProjectName, { instanceId = n
|
|
|
51
139
|
if (!entry.isDirectory()) continue;
|
|
52
140
|
const project = entry.name;
|
|
53
141
|
const projectDir = join(logDir, project);
|
|
54
|
-
//
|
|
142
|
+
// A single unreadable project dir (permissions / concurrent removal /
|
|
143
|
+
// EMFILE) only skips that project — it must not sink the whole list.
|
|
55
144
|
try {
|
|
56
|
-
const owns = logFileMatcher(project, instanceId);
|
|
57
145
|
const files = (await readdir(projectDir)).filter(isLogFileName);
|
|
58
146
|
let statsFiles = null;
|
|
59
147
|
try {
|
|
@@ -61,36 +149,26 @@ export async function listLocalLogs(logDir, currentProjectName, { instanceId = n
|
|
|
61
149
|
if (existsSync(statsFile)) {
|
|
62
150
|
statsFiles = JSON.parse(await readFile(statsFile, 'utf-8')).files;
|
|
63
151
|
}
|
|
64
|
-
} catch { }
|
|
152
|
+
} catch { /* stats are cosmetic (turns/preview) — missing/corrupt is fine */ }
|
|
65
153
|
for (const f of files) {
|
|
66
|
-
|
|
67
|
-
const match = f.match(/^(.+?)_(\d{8}_\d{6})\.jsonl(\.zip)?$/);
|
|
154
|
+
const match = f.match(/^(.+?)_(\d{8}_\d{6})\.jsonl$/);
|
|
68
155
|
if (!match) continue;
|
|
69
156
|
const ts = match[2];
|
|
70
|
-
const archived = !!match[3];
|
|
71
|
-
// 从前缀解析归属实例 id 用于行内 badge:`<pid>__<project>` → pid;`<project>` → 无(null)。
|
|
72
|
-
// 项目名自身可含下划线,故用 endsWith('__'+project) 精确剥离,而非按 '__' split。
|
|
73
|
-
const g1 = match[1];
|
|
74
|
-
const fileInstanceId = (g1 !== project && g1.endsWith('__' + project))
|
|
75
|
-
? g1.slice(0, -(project.length + 2))
|
|
76
|
-
: null;
|
|
77
157
|
const filePath = join(projectDir, f);
|
|
78
158
|
let size;
|
|
79
159
|
try { size = (await stat(filePath)).size; } catch { continue; }
|
|
80
160
|
if (size === 0) continue;
|
|
81
|
-
const stats = statsFiles?.[f] ||
|
|
161
|
+
const stats = statsFiles?.[f] || null;
|
|
82
162
|
const turns = stats?.summary?.sessionCount || 0;
|
|
83
163
|
if (!grouped[project]) grouped[project] = [];
|
|
84
|
-
grouped[project].push({ file: `${project}/${f}`, timestamp: ts, size, turns, preview: stats?.preview || []
|
|
164
|
+
grouped[project].push({ file: `${project}/${f}`, timestamp: ts, size, turns, preview: stats?.preview || [] });
|
|
85
165
|
}
|
|
86
|
-
// 按时间戳降序(文件名降序兜底,处理同秒 tie,保证确定性)。
|
|
87
|
-
// 不能再依赖文件名整串排序:`<pid>__` 前缀会把最新日志排到最底。
|
|
88
166
|
if (grouped[project]) {
|
|
89
167
|
grouped[project].sort((a, b) =>
|
|
90
168
|
b.timestamp.localeCompare(a.timestamp) || b.file.localeCompare(a.file));
|
|
91
169
|
}
|
|
92
170
|
} catch (err) {
|
|
93
|
-
console.error(`[CC Viewer] listLocalLogs:
|
|
171
|
+
console.error(`[CC Viewer] listLocalLogs: skipping unreadable project ${project}:`, err?.message || err);
|
|
94
172
|
continue;
|
|
95
173
|
}
|
|
96
174
|
}
|
|
@@ -98,41 +176,32 @@ export async function listLocalLogs(logDir, currentProjectName, { instanceId = n
|
|
|
98
176
|
}
|
|
99
177
|
|
|
100
178
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* same dir" scan would cross --pid instances and return another
|
|
105
|
-
* conversation's log. Archived (.jsonl.zip) predecessors are valid results:
|
|
106
|
-
* readers resolve them transparently via resolveJsonlPath. Returns the
|
|
107
|
-
* absolute path, or null when there is no strictly-older owned segment.
|
|
179
|
+
* v1 files that WOULD appear as rows in the v1 view (same filter as
|
|
180
|
+
* listLocalLogs: timestamped `.jsonl`, non-empty). Gates the v1-view entry
|
|
181
|
+
* link — kept beside the lister so badge count and rows can't drift.
|
|
108
182
|
*/
|
|
109
|
-
export function
|
|
183
|
+
export function countListedV1Files(projectDir) {
|
|
184
|
+
if (!existsSync(projectDir)) return 0;
|
|
185
|
+
let n = 0;
|
|
110
186
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (!currentTs || !existsSync(dir)) return null;
|
|
115
|
-
const owns = logFileMatcher(projectName, instanceId);
|
|
116
|
-
let best = null;
|
|
117
|
-
let bestTs = '';
|
|
118
|
-
for (const f of readdirSync(dir)) {
|
|
119
|
-
if (!isLogFileName(f) || !owns(f)) continue;
|
|
120
|
-
const ts = parseLogTs(f);
|
|
121
|
-
if (!ts || ts >= currentTs) continue;
|
|
122
|
-
if (ts > bestTs || (ts === bestTs && best !== null && f > basename(best))) {
|
|
123
|
-
best = join(dir, f);
|
|
124
|
-
bestTs = ts;
|
|
125
|
-
}
|
|
187
|
+
for (const f of readdirSync(projectDir)) {
|
|
188
|
+
if (!/^(.+?)_(\d{8}_\d{6})\.jsonl$/.test(f)) continue;
|
|
189
|
+
try { if (statSync(join(projectDir, f)).size > 0) n++; } catch { /* raced away */ }
|
|
126
190
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
191
|
+
} catch { return 0; }
|
|
192
|
+
return n;
|
|
131
193
|
}
|
|
132
194
|
|
|
195
|
+
// v1 `.jsonl` ONLY — a `v2:` ref would validate but then mis-join below;
|
|
196
|
+
// v2 reads go through log-stream's session-dir dispatch, never this helper.
|
|
133
197
|
export async function readLocalLog(logDir, file) {
|
|
134
|
-
validateLogPath(logDir, file);
|
|
135
|
-
|
|
198
|
+
const realPath = validateLogPath(logDir, file);
|
|
199
|
+
if (file.startsWith('v2:')) {
|
|
200
|
+
const err = new Error('readLocalLog does not support v2 refs');
|
|
201
|
+
err.code = 'INVALID_INPUT';
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
const filePath = realPath;
|
|
136
205
|
const content = await readFile(filePath, 'utf-8');
|
|
137
206
|
const parsed = content.split('\n---\n').filter(line => line.trim()).map(entry => {
|
|
138
207
|
try { return JSON.parse(entry); } catch { return null; }
|
|
@@ -145,9 +214,78 @@ export async function readLocalLog(logDir, file) {
|
|
|
145
214
|
return reconstructEntries(Array.from(map.values()));
|
|
146
215
|
}
|
|
147
216
|
|
|
148
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Soft-delete log units — 1.7.0: NOTHING is ever unlinked.
|
|
219
|
+
* - `v2:<project>/<sid>` refs: the session dir is renamed into the project's
|
|
220
|
+
* `sessions-removed-<YYYYMMDD>/` recycle dir (same convention the converter
|
|
221
|
+
* uses for superseded output; move the dir back to restore).
|
|
222
|
+
* - legacy `.jsonl` names: renamed into `removed-<YYYYMMDD>/` beside them —
|
|
223
|
+
* the UI no longer lists v1 files, but the API stays safe if called.
|
|
224
|
+
* A session owned by a LIVE process is refused: primary check is the caller's
|
|
225
|
+
* own writer (opts.liveSessionDir); cross-process, meta.pid liveness and a
|
|
226
|
+
* fresh journal mtime both refuse.
|
|
227
|
+
*/
|
|
228
|
+
export function deleteLogFiles(logDir, files, opts = {}) {
|
|
229
|
+
const liveSessionDir = opts.liveSessionDir || null;
|
|
230
|
+
const now = opts.now || Date.now;
|
|
231
|
+
const kill = opts.processKill || ((pid) => process.kill(pid, 0));
|
|
232
|
+
const stamp = (() => {
|
|
233
|
+
const d = new Date(now());
|
|
234
|
+
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
|
|
235
|
+
})();
|
|
149
236
|
const results = [];
|
|
237
|
+
const realLogDir = realpathSync(logDir);
|
|
150
238
|
for (const file of files) {
|
|
239
|
+
// ── v2 session refs ──
|
|
240
|
+
const ref = parseV2Ref(file);
|
|
241
|
+
if (ref) {
|
|
242
|
+
try {
|
|
243
|
+
const dir = join(logDir, ref.project, 'sessions', ref.sessionId);
|
|
244
|
+
if (!existsSync(dir)) {
|
|
245
|
+
results.push({ file, error: 'Not found' });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const realPath = realpathSync(dir);
|
|
249
|
+
if (!realPath.startsWith(realLogDir + sep)) {
|
|
250
|
+
results.push({ file, error: 'Access denied' });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (liveSessionDir && realPath === realpathSync(liveSessionDir)) {
|
|
254
|
+
results.push({ file, error: 'Session is live (owned by this process)' });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
let live = false;
|
|
258
|
+
try {
|
|
259
|
+
const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8'));
|
|
260
|
+
if (meta && typeof meta.pid === 'number' && meta.pid !== process.pid) {
|
|
261
|
+
try { kill(meta.pid); live = true; } catch (err) {
|
|
262
|
+
// EPERM means the pid EXISTS under another user — that IS live
|
|
263
|
+
// (review P1: cross-user LOG_DIR sharing must not soft-delete a
|
|
264
|
+
// session out from under its writer). Anything else = pid gone.
|
|
265
|
+
if (err && err.code === 'EPERM') live = true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} catch { /* unreadable meta — fall through to the mtime guard */ }
|
|
269
|
+
try {
|
|
270
|
+
const jStat = statSync(join(dir, 'journal.jsonl'));
|
|
271
|
+
if (now() - jStat.mtimeMs < LIVE_SESSION_MTIME_MS) live = true;
|
|
272
|
+
} catch { /* no journal — nothing fresh to protect */ }
|
|
273
|
+
if (live) {
|
|
274
|
+
results.push({ file, error: 'Session looks live (recent activity or owner process running)' });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const recycleDir = join(logDir, ref.project, `sessions-removed-${stamp}`);
|
|
278
|
+
mkdirSync(recycleDir, { recursive: true });
|
|
279
|
+
let target = join(recycleDir, ref.sessionId);
|
|
280
|
+
if (existsSync(target)) target = `${target}-${now()}`;
|
|
281
|
+
renameSync(realPath, target);
|
|
282
|
+
results.push({ file, ok: true, movedTo: target });
|
|
283
|
+
} catch (err) {
|
|
284
|
+
results.push({ file, error: err.message });
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
// ── legacy v1 files ──
|
|
151
289
|
if (!file || file.includes('..') || !isLogFileName(file)) {
|
|
152
290
|
results.push({ file, error: 'Invalid file name' });
|
|
153
291
|
continue;
|
|
@@ -159,13 +297,16 @@ export function deleteLogFiles(logDir, files) {
|
|
|
159
297
|
continue;
|
|
160
298
|
}
|
|
161
299
|
const realPath = realpathSync(filePath);
|
|
162
|
-
const realLogDir = realpathSync(logDir);
|
|
163
300
|
if (realPath !== realLogDir && !realPath.startsWith(realLogDir + sep)) {
|
|
164
301
|
results.push({ file, error: 'Access denied' });
|
|
165
302
|
continue;
|
|
166
303
|
}
|
|
167
|
-
|
|
168
|
-
|
|
304
|
+
const recycleDir = join(dirname(realPath), `removed-${stamp}`);
|
|
305
|
+
mkdirSync(recycleDir, { recursive: true });
|
|
306
|
+
let target = join(recycleDir, basename(realPath));
|
|
307
|
+
if (existsSync(target)) target = target.replace(/\.jsonl$/, `-${now()}.jsonl`);
|
|
308
|
+
renameSync(realPath, target);
|
|
309
|
+
results.push({ file, ok: true, movedTo: target });
|
|
169
310
|
} catch (err) {
|
|
170
311
|
results.push({ file, error: err.message });
|
|
171
312
|
}
|
|
@@ -173,181 +314,7 @@ export function deleteLogFiles(logDir, files) {
|
|
|
173
314
|
return results;
|
|
174
315
|
}
|
|
175
316
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
throw err;
|
|
181
|
-
}
|
|
182
|
-
for (const f of files) {
|
|
183
|
-
if (typeof f === 'string' && f.endsWith('.jsonl.zip')) {
|
|
184
|
-
const err = new Error('Cannot merge archived (.jsonl.zip) files');
|
|
185
|
-
err.code = 'INVALID_INPUT';
|
|
186
|
-
throw err;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
const projects = new Set(files.map(f => f.split(/[\\/]/)[0]));
|
|
190
|
-
if (projects.size !== 1) {
|
|
191
|
-
const err = new Error('All files must belong to the same project');
|
|
192
|
-
err.code = 'INVALID_INPUT';
|
|
193
|
-
throw err;
|
|
194
|
-
}
|
|
195
|
-
for (const f of files) {
|
|
196
|
-
if (f.includes('..')) {
|
|
197
|
-
const err = new Error('Invalid file path');
|
|
198
|
-
err.code = 'INVALID_INPUT';
|
|
199
|
-
throw err;
|
|
200
|
-
}
|
|
201
|
-
if (!existsSync(join(logDir, f))) {
|
|
202
|
-
const err = new Error(`File not found: ${f}`);
|
|
203
|
-
err.code = 'NOT_FOUND';
|
|
204
|
-
throw err;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
const MAX_MERGE_SIZE = 400 * 1024 * 1024;
|
|
208
|
-
let totalSize = 0;
|
|
209
|
-
for (const f of files) {
|
|
210
|
-
totalSize += (await stat(join(logDir, f))).size;
|
|
211
|
-
}
|
|
212
|
-
if (totalSize > MAX_MERGE_SIZE) {
|
|
213
|
-
const err = new Error(`Merged size (${(totalSize / 1024 / 1024).toFixed(1)}MB) exceeds ${MAX_MERGE_SIZE / 1024 / 1024}MB limit`);
|
|
214
|
-
err.code = 'INVALID_INPUT';
|
|
215
|
-
throw err;
|
|
216
|
-
}
|
|
217
|
-
const targetFile = files[0];
|
|
218
|
-
const targetPath = join(logDir, targetFile);
|
|
219
|
-
const tmpPath = `${targetPath}.merge-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
220
|
-
await writeFile(tmpPath, '');
|
|
221
|
-
for (const f of files) {
|
|
222
|
-
const filePath = join(logDir, f);
|
|
223
|
-
await streamReconstructedEntriesAsync(filePath, async (segment) => {
|
|
224
|
-
let chunk = '';
|
|
225
|
-
for (const entry of segment) {
|
|
226
|
-
// Rotation-context sentinels describe a predecessor relationship that a
|
|
227
|
-
// merged file no longer has — drop them from the merge product.
|
|
228
|
-
if (entry.ccvRotationContext) continue;
|
|
229
|
-
// 乱序/断裂条目若未被补偿回填(messages 仍是裸 delta 切片),丢弃不写:
|
|
230
|
-
// 剥除 _deltaFormat 后它会伪装成旧格式全量条目,未来读取时把累积状态
|
|
231
|
-
// 重置成几条切片,比丢掉这条(内容已被更新条目取代)破坏大得多
|
|
232
|
-
if ((entry._staleReorder || entry._reconstructBroken) &&
|
|
233
|
-
entry._totalMessageCount && Array.isArray(entry.body?.messages) &&
|
|
234
|
-
entry.body.messages.length !== entry._totalMessageCount) {
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
delete entry._deltaFormat;
|
|
238
|
-
delete entry._totalMessageCount;
|
|
239
|
-
delete entry._conversationId;
|
|
240
|
-
delete entry._isCheckpoint;
|
|
241
|
-
// 信号对的另一半同剥:_isCheckpoint 已剥后,孤立的 _inPlaceReplaceDetected
|
|
242
|
-
// 永远无法触发双信号短路(applyInPlaceLastMsgReplace 要求两者同真),留着
|
|
243
|
-
// 只会误导未来读取;_eagerSnapshot 为已废弃字段(写入点已删),仅历史日志残留
|
|
244
|
-
delete entry._inPlaceReplaceDetected;
|
|
245
|
-
delete entry._eagerSnapshot;
|
|
246
|
-
// 完成序倒置守卫的内部字段不落盘:合并产物是已重建的全量条目,
|
|
247
|
-
// seq 序号与 stale/broken 标记只在重建期有意义,泄漏会让后续读取误判
|
|
248
|
-
delete entry._seq;
|
|
249
|
-
delete entry._seqEpoch;
|
|
250
|
-
delete entry._staleReorder;
|
|
251
|
-
delete entry._reconstructBroken;
|
|
252
|
-
chunk += JSON.stringify(entry) + '\n---\n';
|
|
253
|
-
}
|
|
254
|
-
await appendFile(tmpPath, chunk);
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
renameSyncWithRetry(tmpPath, targetPath);
|
|
258
|
-
for (let i = 1; i < files.length; i++) {
|
|
259
|
-
unlinkSync(join(logDir, files[i]));
|
|
260
|
-
}
|
|
261
|
-
return targetFile;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function migrateStatsCacheKey(projectDir, projectName, oldFileName, newFileName) {
|
|
265
|
-
const statsFile = join(projectDir, `${projectName}.json`);
|
|
266
|
-
if (!existsSync(statsFile)) return;
|
|
267
|
-
try {
|
|
268
|
-
const stats = JSON.parse(readFileSync(statsFile, 'utf-8'));
|
|
269
|
-
if (stats?.files?.[oldFileName]) {
|
|
270
|
-
const entry = stats.files[oldFileName];
|
|
271
|
-
try {
|
|
272
|
-
const zipStat = statSync(join(projectDir, newFileName));
|
|
273
|
-
entry.size = zipStat.size;
|
|
274
|
-
entry.lastModified = zipStat.mtime.toISOString();
|
|
275
|
-
} catch {}
|
|
276
|
-
stats.files[newFileName] = entry;
|
|
277
|
-
delete stats.files[oldFileName];
|
|
278
|
-
writeFileSync(statsFile, JSON.stringify(stats, null, 2));
|
|
279
|
-
}
|
|
280
|
-
} catch {}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
export function archiveLogFiles(logDir, files) {
|
|
284
|
-
const archived = [];
|
|
285
|
-
const skipped = [];
|
|
286
|
-
const failed = [];
|
|
287
|
-
|
|
288
|
-
const byProject = new Map();
|
|
289
|
-
for (const f of files) {
|
|
290
|
-
if (!f || typeof f !== 'string' || f.includes('..') || !f.endsWith('.jsonl')) {
|
|
291
|
-
failed.push({ file: f, reason: 'Invalid file name' });
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
const parts = f.split(/[\\/]/);
|
|
295
|
-
if (parts.length < 2) {
|
|
296
|
-
failed.push({ file: f, reason: 'Invalid file path' });
|
|
297
|
-
continue;
|
|
298
|
-
}
|
|
299
|
-
const project = parts[0];
|
|
300
|
-
if (!byProject.has(project)) byProject.set(project, []);
|
|
301
|
-
byProject.get(project).push(f);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
let realLogDir;
|
|
305
|
-
try { realLogDir = realpathSync(logDir); }
|
|
306
|
-
catch (err) { return { archived, skipped, failed: files.map(f => ({ file: f, reason: err.message })) }; }
|
|
307
|
-
|
|
308
|
-
for (const [project, projectFiles] of byProject) {
|
|
309
|
-
const projectDir = join(logDir, project);
|
|
310
|
-
let latest = null;
|
|
311
|
-
try {
|
|
312
|
-
// 「最新文件不允许归档」——按时间戳判最新(与 listLocalLogs 同口径),
|
|
313
|
-
// 否则文件名整串排序会把 `<pid>__` 前缀的最新活跃日志误判成非最新而放行归档。
|
|
314
|
-
const projectEntries = readdirSync(projectDir)
|
|
315
|
-
.filter(isLogFileName)
|
|
316
|
-
.sort((a, b) => parseLogTs(b).localeCompare(parseLogTs(a)) || b.localeCompare(a));
|
|
317
|
-
latest = projectEntries[0] || null;
|
|
318
|
-
} catch {}
|
|
319
|
-
|
|
320
|
-
for (const f of projectFiles) {
|
|
321
|
-
const fileName = f.split(/[\\/]/).slice(1).join('/');
|
|
322
|
-
if (latest && fileName === latest) {
|
|
323
|
-
skipped.push({ file: f, reason: 'latest-not-allowed' });
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
const filePath = join(logDir, f);
|
|
327
|
-
let realPath;
|
|
328
|
-
try {
|
|
329
|
-
if (!existsSync(filePath)) { failed.push({ file: f, reason: 'Not found' }); continue; }
|
|
330
|
-
realPath = realpathSync(filePath);
|
|
331
|
-
if (realPath !== realLogDir && !realPath.startsWith(realLogDir + sep)) {
|
|
332
|
-
failed.push({ file: f, reason: 'Access denied' });
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
} catch (err) {
|
|
336
|
-
failed.push({ file: f, reason: err.message });
|
|
337
|
-
continue;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const result = archiveJsonl(realPath);
|
|
341
|
-
if (result.ok) {
|
|
342
|
-
archived.push(f);
|
|
343
|
-
migrateStatsCacheKey(projectDir, project, fileName, fileName + '.zip');
|
|
344
|
-
} else if (result.skipped) {
|
|
345
|
-
skipped.push({ file: f, reason: result.skipped });
|
|
346
|
-
} else {
|
|
347
|
-
failed.push({ file: f, reason: result.error || 'archive failed' });
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
return { archived, skipped, failed };
|
|
353
|
-
}
|
|
317
|
+
// A session whose journal moved within this window is treated as live and
|
|
318
|
+
// refused by deleteLogFiles (cross-process guard; the in-process guard is the
|
|
319
|
+
// caller's own writer state).
|
|
320
|
+
export const LIVE_SESSION_MTIME_MS = 5 * 60 * 1000;
|