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
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Wire Format v2 — S4 dual-write consistency verifier (plan gate methodology).
|
|
2
|
+
//
|
|
3
|
+
// Compares one v1 JSONL file against the v2 session dirs of the same project,
|
|
4
|
+
// per request, on WIRE TRUTH:
|
|
5
|
+
// ① per-request wire-messages digest: v1 incremental delta reconstruction vs
|
|
6
|
+
// v2 mechanical replay (this is trajectory-grade — every intermediate
|
|
7
|
+
// state is compared, not just the cold final one);
|
|
8
|
+
// ② per-request tools/system content hash: v1 inline value hashed with the
|
|
9
|
+
// BlobStore formula vs the v2 journal's CAS ref (byte-equality by CAS);
|
|
10
|
+
// ③ teammate entries verified the same per-request way (v1's leader file
|
|
11
|
+
// holds them inline; v2 holds them in the teammate's own session dir —
|
|
12
|
+
// matching is by `${ts}|${url}`, so no re-join ordering is needed here);
|
|
13
|
+
// ④ inflight/error accounting: v1 keys that never completed vs v2
|
|
14
|
+
// req-without-done;
|
|
15
|
+
// ⑤ journal msgTo vs replayed length integrity (internal v2 consistency).
|
|
16
|
+
//
|
|
17
|
+
// v1-only requests are counted, not failed: history from before dual-write was
|
|
18
|
+
// enabled (or while it was toggled off) legitimately has no v2 twin.
|
|
19
|
+
//
|
|
20
|
+
// KNOWN LIMITATION (review): the ① digest is built on the client-shared
|
|
21
|
+
// messageFingerprint, which keys tool_result blocks on tool_use_id ONLY — a
|
|
22
|
+
// divergence confined to tool_result BODY content between v1 and v2 collides
|
|
23
|
+
// to identical digests and is invisible to this gate. The write side now uses
|
|
24
|
+
// the content-aware fingerprintMsg for its judgement (conversation-store.js),
|
|
25
|
+
// which removes the known producer of that divergence class; the residual risk
|
|
26
|
+
// is a v2 bug that corrupts tool_result content while preserving ids.
|
|
27
|
+
|
|
28
|
+
import { statSync } from 'node:fs';
|
|
29
|
+
import { dirname } from 'node:path';
|
|
30
|
+
import { iterateRawEntriesAsync } from '../log-stream.js';
|
|
31
|
+
import { createIncrementalReconstructor } from '../delta-reconstructor.js';
|
|
32
|
+
import { listSessionIds, readSession, indexSession, messagesDigest, blobRefOf } from './replay.js';
|
|
33
|
+
|
|
34
|
+
const MAX_REPORTED_DIFFS = 50;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Verify one v1 file against its project's v2 sessions.
|
|
38
|
+
* @param {string} v1File - absolute path to a v1 .jsonl (its dirname is the project dir)
|
|
39
|
+
* @param {object} [opts]
|
|
40
|
+
* @param {string} [opts.projectDir] - override project dir (defaults to dirname(v1File))
|
|
41
|
+
* @param {string} [opts.sessionsDirName] - session-dir name under the project
|
|
42
|
+
* dir; the offline converter verifies its staging area ('sessions-migrating')
|
|
43
|
+
* before promoting. Defaults to the live 'sessions'.
|
|
44
|
+
* @param {(p: {entriesScanned: number}) => void} [opts.onProgress] - called
|
|
45
|
+
* every 1000 scanned v1 entries and once after the scan completes, so a
|
|
46
|
+
* long-running verify of one big file can surface live progress. Callback
|
|
47
|
+
* errors are swallowed — progress must never break the verification.
|
|
48
|
+
* @returns {Promise<object>} report (see shape at the bottom)
|
|
49
|
+
*/
|
|
50
|
+
export async function verifyV1File(v1File, opts = {}) {
|
|
51
|
+
const projectDir = opts.projectDir || dirname(v1File);
|
|
52
|
+
const sessionsDirName = opts.sessionsDirName || 'sessions';
|
|
53
|
+
const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : null;
|
|
54
|
+
|
|
55
|
+
// ---- v2 side: index every session of the project --------------------------
|
|
56
|
+
// `${ts}|${url}` is NOT unique in real logs (same-millisecond countTokens
|
|
57
|
+
// bursts, §3.6 rotation twins) — keep ALL records per key and match each v1
|
|
58
|
+
// entry to its best unmatched candidate below (real-data finding 2026-07-14).
|
|
59
|
+
const v2Index = new Map(); // key → rec[]
|
|
60
|
+
const integrity = [];
|
|
61
|
+
const v2Sessions = [];
|
|
62
|
+
const unsupportedSessions = []; // reader version gate (spec §14): a session
|
|
63
|
+
// this build can't read means the gate has a coverage hole — FAIL, don't skip.
|
|
64
|
+
for (const sid of listSessionIds(projectDir, sessionsDirName)) {
|
|
65
|
+
const session = readSession(projectDir, sid, sessionsDirName);
|
|
66
|
+
// Report the session IDENTITY (UUID from meta), not the dir name — the dir
|
|
67
|
+
// is `<ts>_<uuid>` post task C; falls back to the dir name on torn meta.
|
|
68
|
+
v2Sessions.push((session.meta && session.meta.sessionId) || sid);
|
|
69
|
+
if (session.unsupported) {
|
|
70
|
+
unsupportedSessions.push({ sessionId: sid, wireFormat: session.wireFormat });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const { index, integrity: viol } = indexSession(session);
|
|
74
|
+
integrity.push(...viol);
|
|
75
|
+
for (const [key, recs] of index) {
|
|
76
|
+
const arr = v2Index.get(key);
|
|
77
|
+
if (arr) arr.push(...recs); else v2Index.set(key, [...recs]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- v1 side: stream, reconstruct incrementally, compare ------------------
|
|
82
|
+
const reconstructor = createIncrementalReconstructor();
|
|
83
|
+
const diffs = [];
|
|
84
|
+
const inflightV1 = new Set(); // keys seen only as inProgress
|
|
85
|
+
const counters = {
|
|
86
|
+
v1Completed: 0, v1Skipped: 0, matched: 0, v1Only: 0, v1DuplicateKey: 0,
|
|
87
|
+
heartbeatsV1: 0, countTokensV1: 0, teammateV1: 0, staleOrBroken: 0,
|
|
88
|
+
};
|
|
89
|
+
// Per-session suspicion tally, keyed by the v2 session dir name (the same
|
|
90
|
+
// value `listSessionIds` / the converter's promote loop iterate). UNCAPPED —
|
|
91
|
+
// unlike `diffs` (bounded by MAX_REPORTED_DIFFS for memory/reporting), this
|
|
92
|
+
// must hold EVERY suspect session so the converter can quarantine the full
|
|
93
|
+
// set; capping it would silently promote corrupt sessions past diff #50.
|
|
94
|
+
const suspectSessions = new Map(); // sessionId → { count, reasons: string[] }
|
|
95
|
+
const MAX_REASONS = 5;
|
|
96
|
+
const flagSession = (sessionId, reason) => {
|
|
97
|
+
if (!sessionId) return;
|
|
98
|
+
let rec = suspectSessions.get(sessionId);
|
|
99
|
+
if (!rec) suspectSessions.set(sessionId, rec = { count: 0, reasons: [] });
|
|
100
|
+
rec.count++;
|
|
101
|
+
if (rec.reasons.length < MAX_REASONS) rec.reasons.push(reason);
|
|
102
|
+
};
|
|
103
|
+
const addDiff = (d) => {
|
|
104
|
+
flagSession(d.sessionId, `${d.type}@seq${d.seq}`);
|
|
105
|
+
if (diffs.length < MAX_REPORTED_DIFFS) diffs.push(d);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const onScan = (raw) => {
|
|
109
|
+
let entry;
|
|
110
|
+
try { entry = JSON.parse(raw); } catch { return; }
|
|
111
|
+
if (entry.ccvRotationContext) return;
|
|
112
|
+
const key = `${entry.timestamp}|${entry.url}`;
|
|
113
|
+
if (entry.inProgress) { inflightV1.add(key); return; }
|
|
114
|
+
inflightV1.delete(key);
|
|
115
|
+
|
|
116
|
+
// Incremental reconstruction restores the full wire messages for delta
|
|
117
|
+
// entries (teammate/sub entries pass through untouched, already full).
|
|
118
|
+
reconstructor.reconstruct(entry);
|
|
119
|
+
if (entry._staleReorder || entry._reconstructBroken) { counters.staleOrBroken++; return; }
|
|
120
|
+
counters.v1Completed++;
|
|
121
|
+
if (entry.isHeartbeat) counters.heartbeatsV1++;
|
|
122
|
+
if (entry.isCountTokens) counters.countTokensV1++;
|
|
123
|
+
if (entry.teammate) counters.teammateV1++;
|
|
124
|
+
|
|
125
|
+
const candidates = v2Index.get(key);
|
|
126
|
+
if (!candidates) { counters.v1Only++; return; }
|
|
127
|
+
const v1Digest = entry.body && Array.isArray(entry.body.messages) ? messagesDigest(entry.body.messages) : null;
|
|
128
|
+
const unmatched = candidates.filter(c => !c._matched);
|
|
129
|
+
if (unmatched.length === 0) {
|
|
130
|
+
// Every v2 twin already consumed: this v1 line is a duplicate completed
|
|
131
|
+
// write of the same request (§3.6 rotation race) — count, don't fail.
|
|
132
|
+
counters.v1DuplicateKey++;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Best candidate: digest-equal first (same-key bursts carry different
|
|
136
|
+
// payloads — pairing by content avoids cross-matching twins), else FIFO.
|
|
137
|
+
const v2 = (v1Digest && unmatched.find(c => c.digest === v1Digest)) || unmatched[0];
|
|
138
|
+
counters.matched++;
|
|
139
|
+
v2._matched = true;
|
|
140
|
+
|
|
141
|
+
// ① wire-messages digest (skip heartbeats — no conversation on either side)
|
|
142
|
+
if (v2.conv && v1Digest != null) {
|
|
143
|
+
if (v1Digest !== v2.digest) {
|
|
144
|
+
addDiff({ type: 'messages-digest', sessionId: v2.sessionId, key, seq: v2.seq, conv: v2.conv, v1: v1Digest, v2: v2.digest, v1Len: entry.body.messages.length, v2Len: v2.len });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// ② tools/system CAS equality
|
|
148
|
+
const v1Tools = blobRefOf(entry.body && entry.body.tools);
|
|
149
|
+
const v1Sys = blobRefOf(entry.body && entry.body.system);
|
|
150
|
+
if (v1Tools !== v2.toolsRef) addDiff({ type: 'tools-ref', sessionId: v2.sessionId, key, seq: v2.seq, v1: v1Tools, v2: v2.toolsRef });
|
|
151
|
+
if (v1Sys !== v2.sysRef) addDiff({ type: 'system-ref', sessionId: v2.sessionId, key, seq: v2.seq, v1: v1Sys, v2: v2.sysRef });
|
|
152
|
+
// ③ teammate attribution
|
|
153
|
+
if (!!entry.teammate !== (v2.kind === 'teammate')) {
|
|
154
|
+
addDiff({ type: 'teammate-kind', sessionId: v2.sessionId, key, seq: v2.seq, v1: !!entry.teammate, v2: v2.kind });
|
|
155
|
+
}
|
|
156
|
+
// ④ completion accounting
|
|
157
|
+
if (!v2.done) addDiff({ type: 'missing-done', sessionId: v2.sessionId, key, seq: v2.seq });
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Single streaming pass (1MB chunks): verify must stay memory-bounded on
|
|
161
|
+
// 300MB soak files — no dedup map, no second pass (review P2).
|
|
162
|
+
let entriesScanned = 0;
|
|
163
|
+
for await (const raw of iterateRawEntriesAsync(v1File)) {
|
|
164
|
+
onScan(raw);
|
|
165
|
+
entriesScanned++;
|
|
166
|
+
if (onProgress && entriesScanned % 1000 === 0) {
|
|
167
|
+
try { onProgress({ entriesScanned }); } catch { /* progress must never break the verify */ }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (onProgress) {
|
|
171
|
+
try { onProgress({ entriesScanned }); } catch { /* progress must never break the verify */ }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// v2 records never matched by any v1 entry in THIS file: only suspicious if
|
|
175
|
+
// their req/done both exist (a live v2-only write) — they may simply belong
|
|
176
|
+
// to another v1 segment (rotation), so count, don't fail.
|
|
177
|
+
let v2Unmatched = 0;
|
|
178
|
+
const v2ReqWithoutDone = [];
|
|
179
|
+
for (const [key, recs] of v2Index) {
|
|
180
|
+
for (const rec of recs) {
|
|
181
|
+
if (!rec._matched) v2Unmatched++;
|
|
182
|
+
if (!rec.done) v2ReqWithoutDone.push(key);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Integrity violations and unsupported-wire sessions are collected during the
|
|
187
|
+
// v2-side indexing pass above (before `flagSession` exists), so fold them into
|
|
188
|
+
// the per-session tally here — they condemn a session just as a diff does.
|
|
189
|
+
for (const v of integrity) flagSession(v.sessionId, `integrity@seq${v.seq}`);
|
|
190
|
+
for (const u of unsupportedSessions) flagSession(u.sessionId, `unsupported:${u.wireFormat}`);
|
|
191
|
+
|
|
192
|
+
const fileSize = (() => { try { return statSync(v1File).size; } catch { return 0; } })();
|
|
193
|
+
const report = {
|
|
194
|
+
v1File,
|
|
195
|
+
projectDir,
|
|
196
|
+
fileSize,
|
|
197
|
+
v2Sessions,
|
|
198
|
+
counters: { ...counters, v2Requests: v2Index.size, v2Unmatched, v1InflightOnly: inflightV1.size, v2ReqWithoutDone: v2ReqWithoutDone.length },
|
|
199
|
+
diffs,
|
|
200
|
+
integrity,
|
|
201
|
+
unsupportedSessions,
|
|
202
|
+
// Complete (uncapped) set of suspect sessions for the converter to quarantine.
|
|
203
|
+
suspectSessions: [...suspectSessions.entries()].map(([sessionId, r]) => ({ sessionId, count: r.count, reasons: r.reasons })),
|
|
204
|
+
ok: diffs.length === 0 && integrity.length === 0 && unsupportedSessions.length === 0,
|
|
205
|
+
};
|
|
206
|
+
return report;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Human-readable one-screen rendering for the CLI. */
|
|
210
|
+
export function renderReport(report) {
|
|
211
|
+
const c = report.counters;
|
|
212
|
+
const lines = [];
|
|
213
|
+
lines.push(`wire-v2 verify — ${report.v1File}`);
|
|
214
|
+
lines.push(` project: ${report.projectDir}`);
|
|
215
|
+
lines.push(` v2 sessions scanned: ${report.v2Sessions.length}, v2 requests indexed: ${c.v2Requests}`);
|
|
216
|
+
lines.push(` v1 completed: ${c.v1Completed} (heartbeat ${c.heartbeatsV1}, countTokens ${c.countTokensV1}, teammate ${c.teammateV1}, stale/broken skipped ${c.staleOrBroken})`);
|
|
217
|
+
lines.push(` matched: ${c.matched} v1-only (no v2 twin — pre-dual-write history): ${c.v1Only} v1-duplicate-key (rotation twins): ${c.v1DuplicateKey || 0} v2-unmatched (other segments): ${c.v2Unmatched}`);
|
|
218
|
+
lines.push(` inflight: v1-only ${c.v1InflightOnly}, v2 req-without-done ${c.v2ReqWithoutDone}`);
|
|
219
|
+
if (report.unsupportedSessions && report.unsupportedSessions.length > 0) {
|
|
220
|
+
lines.push(` UNSUPPORTED WIRE FORMAT (reader version gate): ${report.unsupportedSessions.length}`);
|
|
221
|
+
for (const u of report.unsupportedSessions.slice(0, 10)) lines.push(` ${u.sessionId} wireFormat=${u.wireFormat}`);
|
|
222
|
+
}
|
|
223
|
+
if (report.integrity.length > 0) {
|
|
224
|
+
lines.push(` INTEGRITY VIOLATIONS (journal msgTo vs replay): ${report.integrity.length}`);
|
|
225
|
+
for (const v of report.integrity.slice(0, 10)) lines.push(` ${v.sessionId} seq=${v.seq} conv=${v.conv} msgTo=${v.msgTo} replayed=${v.replayedLen}`);
|
|
226
|
+
}
|
|
227
|
+
if (report.diffs.length > 0) {
|
|
228
|
+
lines.push(` DIFFS: ${report.diffs.length}${report.diffs.length >= 50 ? '+ (capped)' : ''}`);
|
|
229
|
+
for (const d of report.diffs.slice(0, 10)) lines.push(` [${d.type}] seq=${d.seq} ${d.key}\n v1=${JSON.stringify(d.v1)} v2=${JSON.stringify(d.v2)}`);
|
|
230
|
+
}
|
|
231
|
+
lines.push(report.ok ? ' RESULT: OK — zero diffs' : ' RESULT: FAILED');
|
|
232
|
+
return lines.join('\n');
|
|
233
|
+
}
|
package/server/routes/events.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// SSE event stream + log-registration / resume / turn-end routes (moved verbatim from server.js).
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { _projectName, getLiveLogSource, isContinuedLaunch } from '../interceptor.js';
|
|
5
5
|
import { LOG_DIR } from '../../findcc.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { streamRawEntriesAsync } from '../lib/log-stream.js';
|
|
7
|
+
import { migrationStatus } from '../lib/v2/migrate-prompt.js';
|
|
8
|
+
import { reportSwallowed } from '../lib/error-report.js';
|
|
8
9
|
import { awaitDrainOrClose } from '../lib/sse-backpressure.js';
|
|
9
10
|
import { enrichRawIfNeeded } from '../lib/enrich-plan-input.js';
|
|
10
11
|
import { validateLogPath } from '../lib/log-management.js';
|
|
@@ -49,77 +50,6 @@ function turnEndNotify(req, res, parsedUrl, isLocal, deps) {
|
|
|
49
50
|
});
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
// 注册新的日志文件进行 watch(供新进程复用旧服务时调用)
|
|
53
|
-
function registerLog(req, res, parsedUrl, isLocal, deps) {
|
|
54
|
-
let body = '';
|
|
55
|
-
req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
|
|
56
|
-
req.on('end', () => {
|
|
57
|
-
try {
|
|
58
|
-
const { logFile } = JSON.parse(body);
|
|
59
|
-
if (logFile && typeof logFile === 'string' && logFile.startsWith(LOG_DIR) && existsSync(logFile)) {
|
|
60
|
-
watchLogFile(deps.logWatcherOpts(logFile));
|
|
61
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
62
|
-
res.end(JSON.stringify({ ok: true }));
|
|
63
|
-
} else {
|
|
64
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
65
|
-
res.end(JSON.stringify({ error: 'Invalid log file path' }));
|
|
66
|
-
}
|
|
67
|
-
} catch {
|
|
68
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
69
|
-
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
70
|
-
}
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// 用户选择继续/新开日志
|
|
75
|
-
function resumeChoice(req, res, parsedUrl, isLocal, deps) {
|
|
76
|
-
let body = '';
|
|
77
|
-
req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
|
|
78
|
-
req.on('end', async () => {
|
|
79
|
-
try {
|
|
80
|
-
const { choice } = JSON.parse(body);
|
|
81
|
-
if (choice !== 'continue' && choice !== 'new') {
|
|
82
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
83
|
-
res.end(JSON.stringify({ error: 'Invalid choice' }));
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
const result = resolveResumeChoice(choice);
|
|
87
|
-
if (!result) {
|
|
88
|
-
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
89
|
-
res.end(JSON.stringify({ error: 'Already resolved' }));
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
// 重新 watch 最终的日志文件
|
|
93
|
-
watchLogFile(deps.logWatcherOpts(result.logFile));
|
|
94
|
-
// 广播 resume_resolved + full_reload
|
|
95
|
-
const resolvedData = JSON.stringify({ logFile: result.logFile });
|
|
96
|
-
deps.clients.forEach(client => {
|
|
97
|
-
try {
|
|
98
|
-
client.write(`event: resume_resolved\ndata: ${resolvedData}\n\n`);
|
|
99
|
-
} catch { }
|
|
100
|
-
});
|
|
101
|
-
// 流式分段广播 full_reload,避免全量加载 OOM
|
|
102
|
-
const reloadTotal = await countLogEntries(LOG_FILE);
|
|
103
|
-
deps.clients.forEach(client => {
|
|
104
|
-
try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: reloadTotal, incremental: false })}\n\n`); } catch { }
|
|
105
|
-
});
|
|
106
|
-
await streamRawEntriesAsync(LOG_FILE, (raw) => {
|
|
107
|
-
deps.clients.forEach(client => {
|
|
108
|
-
try { client.write('event: load_chunk\ndata: ['); client.write(raw.replace(/\n/g, '')); client.write(']\n\n'); } catch { }
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
deps.clients.forEach(client => {
|
|
112
|
-
try { client.write(`event: load_end\ndata: {}\n\n`); } catch { }
|
|
113
|
-
});
|
|
114
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
115
|
-
res.end(JSON.stringify({ ok: true, logFile: result.logFile }));
|
|
116
|
-
} catch {
|
|
117
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
118
|
-
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
|
|
123
53
|
// SSE endpoint
|
|
124
54
|
// 构造「有新版」徽标的 SSE 帧(pending = {version, source})。pending 为空返回 null。
|
|
125
55
|
// 抽成纯函数便于单测帧格式;events() 在新连接上调用它补推,使版本徽标跨刷新持续显示。
|
|
@@ -157,16 +87,22 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
157
87
|
console.warn(`[server_config] SSE write failed (turnEndDebounceMs=${deps.turnEndDebounceMs}):`, err && err.message);
|
|
158
88
|
}
|
|
159
89
|
|
|
160
|
-
// 如果有待决的 resume 选择,发送 resume_prompt 事件
|
|
161
|
-
if (_resumeState) {
|
|
162
|
-
res.write(`event: resume_prompt\ndata: ${JSON.stringify({ recentFileName: _resumeState.recentFileName })}\n\n`);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
90
|
// 补推「有新版」徽标:复用启动检查缓存的结果,让刷新/新标签页连上即恢复徽标。
|
|
166
91
|
// 置于 deps.clients.push(res) 之前 → 新客户端仅得一份,不与一次性广播竞态。
|
|
167
92
|
const updFrame = sseUpdateBadgeFrame(deps.pendingMajorUpdate);
|
|
168
93
|
if (updFrame) res.write(updFrame);
|
|
169
94
|
|
|
95
|
+
// 1.7.0 迁移引导(P2):当前项目仍有未转换的 v1 日志 → 连接即推 migrate_prompt。
|
|
96
|
+
// 是否弹窗由客户端决定(「不再提醒」偏好在客户端;continued=true 时无视 dismissed
|
|
97
|
+
// 再提醒一次——`-c` 续接的对话前半段在旧格式里,不迁移就看不到)。工作区切换后的
|
|
98
|
+
// 提示由 workspaces launch 路由对存量连接广播(本帧只覆盖新连接)。
|
|
99
|
+
try {
|
|
100
|
+
const mig = migrationStatus(LOG_DIR, _projectName || '');
|
|
101
|
+
if (mig.pending) {
|
|
102
|
+
res.write(`event: migrate_prompt\ndata: ${JSON.stringify({ ...mig, continued: isContinuedLaunch() })}\n\n`);
|
|
103
|
+
}
|
|
104
|
+
} catch (e) { reportSwallowed('sse.migrate_prompt', e); }
|
|
105
|
+
|
|
170
106
|
// 增量加载参数:移动端带 since/cc/project 请求增量数据
|
|
171
107
|
const sinceParam = parsedUrl.searchParams.get('since');
|
|
172
108
|
const ccParam = parseInt(parsedUrl.searchParams.get('cc'), 10) || 0;
|
|
@@ -202,10 +138,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
202
138
|
// ring=3 的容错意义:团队会话末尾常有连续 teammate 伪 mainAgent 条目,单记忆位会被
|
|
203
139
|
// 挤掉真实 mainAgent;子串预过滤的理论误伤(键/值恰为 "teammate" 的真实条目)也由
|
|
204
140
|
// 环内更早候选兜底。
|
|
141
|
+
// KEEP IN SYNC: server/lib/v2/adapter.js MAINAGENT_RING_DEPTH — on the v2 path
|
|
142
|
+
// the adapter's mainAgentRing (that depth) wholesale REPLACES this ring, so a
|
|
143
|
+
// mismatch would silently give v1 and v2 sessions different candidate depths.
|
|
205
144
|
const MAINAGENT_SCAN_RING = 3;
|
|
206
145
|
const mainAgentRawRing = [];
|
|
207
146
|
|
|
208
|
-
|
|
147
|
+
// S6b: the cold-load source is the current v2 session dir when the v2
|
|
148
|
+
// writer is active (adapter stream), else the v1 file.
|
|
149
|
+
const coldLoadResult = await streamRawEntriesAsync(getLiveLogSource(), async (raw) => {
|
|
209
150
|
// 直接发送原始 JSON 字符串,不做 parse/reconstruct/stringify
|
|
210
151
|
// ExitPlanMode V2 空 input 的条目按需补全 plan / planFilePath,其它原样透传
|
|
211
152
|
if (res.destroyed || !res.writable) return;
|
|
@@ -255,6 +196,14 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
255
196
|
|
|
256
197
|
res.write(`event: load_end\ndata: {}\n\n`);
|
|
257
198
|
|
|
199
|
+
// S10a: v2 sources no longer invoke onScan (the two-pass window never
|
|
200
|
+
// stringifies out-of-window entries) — the adapter returns the newest-main
|
|
201
|
+
// raws as `mainAgentRing` instead. v1 sources still fill the ring via onScan.
|
|
202
|
+
if (Array.isArray(coldLoadResult?.mainAgentRing) && coldLoadResult.mainAgentRing.length > 0) {
|
|
203
|
+
mainAgentRawRing.length = 0;
|
|
204
|
+
mainAgentRawRing.push(...coldLoadResult.mainAgentRing);
|
|
205
|
+
}
|
|
206
|
+
|
|
258
207
|
// Pass 1 入环的候选 newest-first 校验 + parse(≤K 次)。kv_cache 与 context_window
|
|
259
208
|
// 各自取"最新一条能提供该值的真实 mainAgent"—— 与旧版逐条覆盖语义等价(环深度内)。
|
|
260
209
|
for (let ri = mainAgentRawRing.length - 1; ri >= 0 && (!latestKvCache || !latestContextWindow); ri--) {
|
|
@@ -328,7 +277,7 @@ async function requests(req, res) {
|
|
|
328
277
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
329
278
|
res.write('[');
|
|
330
279
|
let first = true;
|
|
331
|
-
await streamRawEntriesAsync(
|
|
280
|
+
await streamRawEntriesAsync(getLiveLogSource(), (raw) => {
|
|
332
281
|
if (!first) res.write(',');
|
|
333
282
|
res.write(enrichRawIfNeeded(raw));
|
|
334
283
|
first = false;
|
|
@@ -337,57 +286,8 @@ async function requests(req, res) {
|
|
|
337
286
|
res.end();
|
|
338
287
|
}
|
|
339
288
|
|
|
340
|
-
// 分页历史条目端点:移动端"加载更多"按需拉取
|
|
341
|
-
// 支持可选 ?file= 参数指定目标文件(用于本地日志分页),默认使用活跃会话文件
|
|
342
|
-
async function entriesPage(req, res, parsedUrl) {
|
|
343
|
-
const before = parsedUrl.searchParams.get('before');
|
|
344
|
-
const limitVal = Math.min(parseInt(parsedUrl.searchParams.get('limit'), 10) || 100, 500);
|
|
345
|
-
if (!before || isNaN(new Date(before).getTime())) {
|
|
346
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
347
|
-
res.end(JSON.stringify({ error: 'missing or invalid "before" parameter' }));
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
const file = parsedUrl.searchParams.get('file');
|
|
351
|
-
let targetFile = LOG_FILE;
|
|
352
|
-
if (file) {
|
|
353
|
-
if (file.includes('..') || (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.zip'))) {
|
|
354
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
355
|
-
res.end(JSON.stringify({ error: 'Invalid file name' }));
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
try { validateLogPath(LOG_DIR, file); } catch (e) {
|
|
359
|
-
const status = e.code === 'NOT_FOUND' ? 404 : e.code === 'ACCESS_DENIED' ? 403 : 400;
|
|
360
|
-
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
361
|
-
res.end(JSON.stringify({ error: e.message }));
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
targetFile = join(LOG_DIR, file);
|
|
365
|
-
}
|
|
366
|
-
try {
|
|
367
|
-
const result = await readPagedEntries(targetFile, { before, limit: limitVal });
|
|
368
|
-
// entries 是原始 JSON 字符串数组,parse 后返回给客户端
|
|
369
|
-
// ExitPlanMode V2 空 input 的条目用 enrichRawIfNeeded 在 raw 阶段补全
|
|
370
|
-
const entries = result.entries.map(raw => {
|
|
371
|
-
try { return JSON.parse(enrichRawIfNeeded(raw)); } catch { return null; }
|
|
372
|
-
}).filter(Boolean);
|
|
373
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
374
|
-
res.end(JSON.stringify({
|
|
375
|
-
entries,
|
|
376
|
-
hasMore: result.hasMore,
|
|
377
|
-
oldestTimestamp: result.oldestTimestamp,
|
|
378
|
-
count: entries.length,
|
|
379
|
-
}));
|
|
380
|
-
} catch (err) {
|
|
381
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
382
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
289
|
export const eventsRoutes = [
|
|
387
290
|
{ method: 'POST', match: 'exact', path: '/api/turn-end-notify', handler: turnEndNotify },
|
|
388
|
-
{ method: 'POST', match: 'exact', path: '/api/register-log', handler: registerLog },
|
|
389
|
-
{ method: 'POST', match: 'exact', path: '/api/resume-choice', handler: resumeChoice },
|
|
390
291
|
{ method: 'GET', match: 'exact', path: '/events', handler: events },
|
|
391
292
|
{ method: 'GET', match: 'exact', path: '/api/requests', handler: requests },
|
|
392
|
-
{ method: 'GET', match: 'exact', path: '/api/entries/page', handler: entriesPage },
|
|
393
293
|
];
|
|
@@ -8,7 +8,7 @@ import { homedir, tmpdir } from 'node:os';
|
|
|
8
8
|
import { execFile, spawn } from 'node:child_process';
|
|
9
9
|
import { bumpWorkspacesVersion } from '../lib/file-access-policy.js';
|
|
10
10
|
import { validateImportDir } from '../lib/file-api.js';
|
|
11
|
-
import {
|
|
11
|
+
import { PROFILE_PATH, _projectName, _logDir } from '../interceptor.js';
|
|
12
12
|
import { LOG_DIR, getClaudeConfigDir } from '../../findcc.js';
|
|
13
13
|
|
|
14
14
|
function upload(req, res, parsedUrl, isLocal, deps) {
|
|
@@ -844,7 +844,9 @@ function createDir(req, res, parsedUrl, isLocal, deps) {
|
|
|
844
844
|
}
|
|
845
845
|
|
|
846
846
|
function openLogDir(req, res) {
|
|
847
|
-
|
|
847
|
+
// Project dir works for both formats (legacy v1 files and the v2 sessions/
|
|
848
|
+
// tree are siblings under it); LOG_DIR is the pre-workspace fallback.
|
|
849
|
+
const dir = _logDir || LOG_DIR;
|
|
848
850
|
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
849
851
|
execFile(cmd, [dir], { windowsHide: true }, () => {});
|
|
850
852
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
package/server/routes/im.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// reports its own in-process adapter (deps.im.getBridgeStatus) — that's what the manager probes.
|
|
22
22
|
import { getDescriptor, loadConfig, loadState, saveConfig } from '../lib/im-config.js';
|
|
23
23
|
import { findRecentLog } from '../lib/interceptor-core.js';
|
|
24
|
+
import { listV2Sessions } from '../lib/v2/adapter.js';
|
|
24
25
|
import { readSenders } from '../lib/im-senders.js';
|
|
25
26
|
import { readImAppendSystem, writeImAppendSystem, buildImAppendSystemPreset, MAX_IM_APPEND_SYSTEM_CHARS } from '../lib/im-append-system.js';
|
|
26
27
|
import { imDir } from '../lib/im-lock.js';
|
|
@@ -256,8 +257,16 @@ function imLogs(req, res, parsedUrl, isLocal, deps) {
|
|
|
256
257
|
const project = `IM_${id}`;
|
|
257
258
|
let latest = null;
|
|
258
259
|
try {
|
|
259
|
-
|
|
260
|
-
|
|
260
|
+
// wire-v2: prefer the newest v2 session (by meta.startTs, journal mtime as
|
|
261
|
+
// tie-break); fall back to legacy v1 files until they are migrated.
|
|
262
|
+
const sessions = listV2Sessions(join(LOG_DIR, project)).filter((s) => !s.leader);
|
|
263
|
+
if (sessions.length > 0) {
|
|
264
|
+
sessions.sort((a, b) => (a.startTs < b.startTs ? 1 : a.startTs > b.startTs ? -1 : 0));
|
|
265
|
+
latest = `v2:${project}/${sessions[0].sid}`; // 直接喂给 /api/local-log?file=
|
|
266
|
+
} else {
|
|
267
|
+
const abs = findRecentLog(join(LOG_DIR, project), project); // 已排除 *_temp.jsonl
|
|
268
|
+
if (abs) latest = `${project}/${basename(abs)}`; // 相对 LOG_DIR
|
|
269
|
+
}
|
|
261
270
|
} catch { /* 无目录/无日志 → latest=null */ }
|
|
262
271
|
res.writeHead(200, JSON_HEADERS);
|
|
263
272
|
res.end(JSON.stringify({ project, latest }));
|