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.
Files changed (126) hide show
  1. package/README.md +13 -1
  2. package/cli.js +116 -37
  3. package/dist/assets/App-DTkacfRW.js +2 -0
  4. package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-C5Q6uUyH.js} +1 -1
  5. package/dist/assets/Mobile-MoBp9c8k.js +1 -0
  6. package/dist/assets/{_baseUniq-CG81mgN_.js → _baseUniq-D9_E5ZDp.js} +1 -1
  7. package/dist/assets/{arc-C161J4f2.js → arc-D4iFrlhb.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-BmbDMdn9.js → architectureDiagram-Q4EWVU46-f1neo7hg.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-DXpMvdxd.js → blockDiagram-DXYQGD6D-DWi9e9tG.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-8Mypg0IU.js → c4Diagram-AHTNJAMY-DAdj49A2.js} +1 -1
  11. package/dist/assets/{channel-B2khdmvc.js → channel-B3VthT0m.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-I61r1g6z.js → chunk-4BX2VUAB-BrPcAHV3.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-VFPlRE5E.js → chunk-4TB4RGXK-Bvopc1V9.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-DvCamg_6.js → chunk-55IACEB6-Juw-7Z3i.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-Dj4Q8RwR.js → chunk-EDXVE4YY-DVYP9tpB.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-_bgQuuJh.js → chunk-FMBD7UC4-VQ7VOXl3.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-3to38LyN.js → chunk-OYMX7WX6-F8Rj4ZUZ.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-Zl26wKKz.js → chunk-QZHKN3VN-Dn7K3yi9.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-RLYPrlh0.js → chunk-YZCP3GAM-UjO-KTsn.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +1 -0
  22. package/dist/assets/clone-BQtvy9Ae.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-CsYOjJ0V.js → cose-bilkent-S5V4N54A-BDhNj8rq.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-C3YXXaFU.js → dagre-KV5264BT-BzdcDuyt.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-DUCOg1ld.js → diagram-5BDNPKRD-DP_SX65N.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-BtlLl_zX.js → diagram-G4DWMVQ6-B-pBFw8Q.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-1Qp9xfOk.js → diagram-MMDJMWI5-6XSfN1eN.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-Cj7VMp3L.js → diagram-TYMM5635-DJvGxvpE.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-CVZVn541.js → erDiagram-SMLLAGMA-CpqdpGYj.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-CiU9Fkkv.js → flowDiagram-DWJPFMVM-C9m7wjwI.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-aRaG97ID.js → ganttDiagram-T4ZO3ILL-CU0XOfY3.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Cr2LJlvX.js → gitGraphDiagram-UUTBAWPF-ipyGULnP.js} +1 -1
  33. package/dist/assets/{graph-B59lDyMn.js → graph-BR2KmES9.js} +1 -1
  34. package/dist/assets/{index-C1ptnCpq.js → index--wgRW_OB.js} +1 -1
  35. package/dist/assets/index-Ac8vnV3v.js +2 -0
  36. package/dist/assets/{index-CRAWA8PC.js → index-C-be29ey.js} +1 -1
  37. package/dist/assets/{index-CSM1Kw8l.js → index-C16fiVhv.js} +1 -1
  38. package/dist/assets/{index-Ddk-kg22.js → index-D5YruOKD.js} +1 -1
  39. package/dist/assets/{index-BnhNIjbj.js → index-D5tSeMU7.js} +1 -1
  40. package/dist/assets/{index-vFEQq-NB.js → index-DuZQ6FqR.js} +1 -1
  41. package/dist/assets/{index-CO2hZ1s4.js → index-OWzSX2T8.js} +1 -1
  42. package/dist/assets/{infoDiagram-42DDH7IO-CnO22bw8.js → infoDiagram-42DDH7IO-BQWQjfFX.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-ByNzpScH.js → ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-yaVaCWPq.js → journeyDiagram-VCZTEJTY-CMbB_4G9.js} +1 -1
  45. package/dist/assets/{jszip.min-BdVFP3OW.js → jszip.min-B6k2t9Ch.js} +1 -1
  46. package/dist/assets/{kanban-definition-6JOO6SKY-RIPR_RQ3.js → kanban-definition-6JOO6SKY-CImdlKN3.js} +1 -1
  47. package/dist/assets/{layout-0rzel0v_.js → layout-DQl4aw09.js} +1 -1
  48. package/dist/assets/{linear-sktN0tB6.js → linear-CLD3aPlK.js} +1 -1
  49. package/dist/assets/mermaid.core-CN35dvKJ.js +7 -0
  50. package/dist/assets/{min-rspGOkz_.js → min-_D86henJ.js} +1 -1
  51. package/dist/assets/{mindmap-definition-QFDTVHPH-BsVzZzTz.js → mindmap-definition-QFDTVHPH-2JVcu1nk.js} +1 -1
  52. package/dist/assets/{pieDiagram-DEJITSTG-CsoWsw4f.js → pieDiagram-DEJITSTG-Dik-m5u7.js} +1 -1
  53. package/dist/assets/{quadrantDiagram-34T5L4WZ-CTMKuCAI.js → quadrantDiagram-34T5L4WZ-Bm5l40Jq.js} +1 -1
  54. package/dist/assets/{requirementDiagram-MS252O5E-C67256te.js → requirementDiagram-MS252O5E-Ck9w5rTj.js} +1 -1
  55. package/dist/assets/{sankeyDiagram-XADWPNL6-X8Y7KnW0.js → sankeyDiagram-XADWPNL6-BbGVCFEf.js} +1 -1
  56. package/dist/assets/seqResourceLoaders-Cd_TAXTb.js +2 -0
  57. package/dist/assets/{seqResourceLoaders-B5Zy_CC_.css → seqResourceLoaders-CxuD1CYl.css} +1 -1
  58. package/dist/assets/{sequenceDiagram-FGHM5R23-Cils0TAp.js → sequenceDiagram-FGHM5R23-vjvEdksk.js} +1 -1
  59. package/dist/assets/{stateDiagram-FHFEXIEX-DFnohvpG.js → stateDiagram-FHFEXIEX-DTqMoiB2.js} +1 -1
  60. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BTX_4o81.js → stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js} +1 -1
  61. package/dist/assets/{timeline-definition-GMOUNBTQ-DrIYFrzZ.js → timeline-definition-GMOUNBTQ-Cw8O-52d.js} +1 -1
  62. package/dist/assets/{vendor-antd-D5HmSDSg.js → vendor-antd-DI7JL-mE.js} +1 -1
  63. package/dist/assets/{vendor-codemirror-C5yuj7ze.js → vendor-codemirror-B9c49dtM.js} +1 -1
  64. package/dist/assets/{vendor-mdxeditor-BcEooTvb.js → vendor-mdxeditor-B6cpBtIE.js} +2 -2
  65. package/dist/assets/{vendor-qrcode-d9LCn0-9.js → vendor-qrcode-Dn90-s-u.js} +1 -1
  66. package/dist/assets/{vendor-virtuoso-K9m4MN6a.js → vendor-virtuoso-CpdSKD_6.js} +1 -1
  67. package/dist/assets/{vennDiagram-DHZGUBPP-lRqBYTr9.js → vennDiagram-DHZGUBPP-DCuqymb7.js} +1 -1
  68. package/dist/assets/{wardley-RL74JXVD-e8YfxpOe.js → wardley-RL74JXVD-BJ20wJUU.js} +1 -1
  69. package/dist/assets/{wardleyDiagram-NUSXRM2D-Bxrg774h.js → wardleyDiagram-NUSXRM2D-Vz6US3qt.js} +1 -1
  70. package/dist/assets/{xychartDiagram-5P7HB3ND-DWhZqhq8.js → xychartDiagram-5P7HB3ND-CzkLKrmi.js} +1 -1
  71. package/dist/index.html +4 -4
  72. package/findcc.js +4 -3
  73. package/package.json +2 -1
  74. package/server/i18n.js +21 -141
  75. package/server/interceptor.js +211 -403
  76. package/server/lib/async-write-queue.js +12 -1
  77. package/server/lib/delta-reconstructor.js +3 -3
  78. package/server/lib/error-report.js +45 -0
  79. package/server/lib/im-log-watcher.js +68 -8
  80. package/server/lib/interceptor-core.js +19 -174
  81. package/server/lib/log-management.js +214 -247
  82. package/server/lib/log-stream.js +59 -108
  83. package/server/lib/log-watcher.js +34 -272
  84. package/server/lib/log-zip.js +148 -0
  85. package/server/lib/session-boundary.js +266 -0
  86. package/server/lib/session-pin-store.js +10 -19
  87. package/server/lib/stats-worker.js +138 -267
  88. package/server/lib/user-prompt-extract.js +250 -0
  89. package/server/lib/v2/adapter.js +1059 -0
  90. package/server/lib/v2/blob-store.js +44 -0
  91. package/server/lib/v2/conversation-store.js +217 -0
  92. package/server/lib/v2/convert-manager.js +79 -0
  93. package/server/lib/v2/convert-worker.js +42 -0
  94. package/server/lib/v2/convert.js +409 -0
  95. package/server/lib/v2/identity.js +204 -0
  96. package/server/lib/v2/journal.js +62 -0
  97. package/server/lib/v2/layout.js +167 -0
  98. package/server/lib/v2/live-feed.js +451 -0
  99. package/server/lib/v2/migrate-prompt.js +54 -0
  100. package/server/lib/v2/replay.js +191 -0
  101. package/server/lib/v2/session-select.js +222 -0
  102. package/server/lib/v2/singleflight.js +104 -0
  103. package/server/lib/v2/v2-writer.js +513 -0
  104. package/server/lib/v2/verify.js +233 -0
  105. package/server/routes/events.js +30 -130
  106. package/server/routes/files-fs.js +4 -2
  107. package/server/routes/im.js +11 -2
  108. package/server/routes/logs.js +194 -152
  109. package/server/routes/preferences.js +0 -3
  110. package/server/routes/project-meta.js +2 -3
  111. package/server/routes/session-pin.js +7 -8
  112. package/server/routes/workspaces.js +44 -9
  113. package/server/server.js +47 -65
  114. package/server/workspace-registry.js +24 -4
  115. package/dist/assets/App-VTo4NVdo.js +0 -2
  116. package/dist/assets/Mobile-CcmYvMm_.js +0 -1
  117. package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
  118. package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
  119. package/dist/assets/clone-md-Mc1-T.js +0 -1
  120. package/dist/assets/index-DCo650PG.js +0 -2
  121. package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
  122. package/dist/assets/seqResourceLoaders-BVozq68i.js +0 -2
  123. package/server/lib/instance-registry.js +0 -57
  124. package/server/lib/jsonl-archive.js +0 -180
  125. package/server/lib/teammate-detect.js +0 -32
  126. package/server/lib/zip-safety.js +0 -68
@@ -1,14 +1,50 @@
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';
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 { streamReconstructedEntriesAsync } from './log-stream.js';
8
- import { archiveJsonl, resolveJsonlPath } from './jsonl-archive.js';
9
- import { logFileMatcher } from './interceptor-core.js';
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') || name.endsWith('.jsonl.zip');
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
- // 必须按时间戳排。无法解析时返回 ''(排到最后)。listLocalLogs archiveLogFiles 共用,防漂移。
35
- function parseLogTs(name) {
36
- const m = name.match(/_(\d{8}_\d{6})\.jsonl(\.zip)?$/);
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
- // instanceId / showAll 为可选项(默认 null / false = 现状的反面:硬隔离):
41
- // - instanceId 非空:只列本实例 `<pid>__<project>_…` 的日志;
42
- // - instanceId 空:只列无标签 `<project>_…` 日志(排除任何 `<pid>__` 文件);
43
- // - showAll=true:越过上面的归属过滤,列出目录下全部日志(顶部「显示全部」开关用)。
44
- // 复用 interceptor-core 的 logFileMatcher(与写入端共用,防命名漂移)。第 3 参可选 → 旧 2 参调用零改动。
45
- export async function listLocalLogs(logDir, currentProjectName, { instanceId = null, showAll = false } = {}) {
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
- // 单个项目目录读失败(权限 / 被并发删除 / EMFILE 等)只跳过该项目,不拖垮整个列表。
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
- if (!showAll && !owns(f)) continue;
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] || (archived ? statsFiles?.[f.slice(0, -4)] : null);
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 || [], archived, instanceId: fileInstanceId });
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: 跳过无法读取的项目 ${project}:`, err?.message || err);
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
- * Locate the log segment immediately preceding currentFile in the same
102
- * directory, for the post-rotation teammate backfill. Ownership is enforced
103
- * via logFileMatcher(projectName, instanceId) a naive "older file in the
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 findPreviousSegment(currentFile, projectName, instanceId = null) {
183
+ export function countListedV1Files(projectDir) {
184
+ if (!existsSync(projectDir)) return 0;
185
+ let n = 0;
110
186
  try {
111
- if (!currentFile || !projectName) return null;
112
- const dir = dirname(currentFile);
113
- const currentTs = parseLogTs(basename(currentFile));
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
- return best;
128
- } catch {
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
- const filePath = resolveJsonlPath(join(logDir, file));
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
- export function deleteLogFiles(logDir, files) {
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
- unlinkSync(realPath);
168
- results.push({ file, ok: true });
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
- export async function mergeLogFiles(logDir, files) {
177
- if (!Array.isArray(files) || files.length < 2) {
178
- const err = new Error('At least 2 files required');
179
- err.code = 'INVALID_INPUT';
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;