Skip to content

Commit b420b8f

Browse files
王超claude
authored andcommitted
feat(wire): brotli transport compression + Wire v3 metadata protocol (default on)
Two stacked wire overhauls, measured on a real 68MB/3830-request session: Axis B — brotli compression (br|identity negotiation, per-event flush): /events and /api/local-log SSE streams and /api/requests compress under Content-Encoding: br via the single seam server/lib/wire-compress.js; every SSE write (broadcasts, workspace reload, update badge) routes through sseWrite so no plaintext byte can corrupt a compressed stream. Encoder-aware backpressure (awaitWireDrain), br;q=0 honored, Vary on both branches, encoder destroyed on close/eviction. Cold load 176.8MB -> 8.5MB under br. Escape hatches: CCV_WIRE_COMPRESSION=off, CCV_BROTLI_QUALITY. Wire v3 — metadata rows + native chat wire (spec docs/refactor/ WIRE_FORMAT_V3.md), DEFAULT ON (CCV_WIRE_V3=0|off is the escape hatch): - request list = journal-fold metadata rows (v2_requests / v2_requests_delta; typeTag via the client's own classifyRequest — the requestType chain now carries .js extensions and src/utils/ ships in the npm files array; cacheLoss computed server-side in a bounded Pass B) - detail on demand: GET /api/v2-entry rebuilds one full entry + prevMain (per-member checkpoint promotion in the window materializer) - chat = raw conv/responses lines (cold: from the last snapshot at-or-before the window start, ~512KB chunked frames; live: per-line forwarding, the full-entry broadcast suppressed); the client v3Assembler replays them into v1-shape entries feeding the EXISTING ingest pipeline (parity oracle vs the legacy reconstructed stream), so merge guards and consumers keep their exact semantics - since-scoped incremental reconnects; single-flighted cold reads; v3 client state resets on every baseline reset (workspace switch, full_reload, fresh cold frame); build-stamp reload guard for tabs that survive a server upgrade - loading UX: no mask — the list is interactive from the first frame, the chat area shows its inline Spin + an exact received/total byte meter (load_start.v3Bytes), and the assembly yields to the main thread Cold plaintext 176.8MB -> 42.5MB (the remote plain-http path browsers cannot brotli), client JSON parse /4.2, live channel per-turn delta-sized instead of re-broadcasting the full history twice per turn (was 3.7GB per client per session). Retained surfaces unchanged: /api/local-log v1-shape output, downloads, workspaces preview, ccv verify, v1 legacy files; entry-slim retained (idle on the v3 path). Hardened by a 6-perspective team review (all P0/P1/P2 adopted): npm packaging fix, baseline-reset lifecycle, assembler race buffering, teammate fixture tests (caught a real teammate-session cold-load crash), legacy loading-state regression, event-loop yields, kv/context depth parity. test:cli 8235/8247 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent cf0c21b commit b420b8f

35 files changed

Lines changed: 3059 additions & 136 deletions

docs/WIRE_FORMAT.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
> 本文档保留为:① `ccv convert` / `ccv verify` 迁移工具的读取契约参考;
55
> ② v2→v1 适配器(`server/lib/v2/adapter.js`)合成的 entry 形态仍遵循本文的
66
> envelope 协议(客户端重建器不变)。写入端章节描述的是已退役的历史行为。
7+
>
8+
> **Wire v3(默认开启)**`/events` 的 v2 会话源已改发元数据行 + 原生
9+
> conv/responses 行(见 `docs/refactor/WIRE_FORMAT_V3.md`);本文的 entry
10+
> envelope 协议仍约束 legacy 通道(`CCV_WIRE_V3=0` 逃生舱、v1 遗留文件、
11+
> `/api/local-log`)与客户端组装器产出的 entry 形态。§3.7 双层重建不变量继续有效。
712
813
服务端到客户端 (`AppBase.jsx` + `sessionManager.js` + `sessionMerge.js`) 的 mainAgent entry 协议规约(v1 时代由 `server/interceptor.js` 写入;1.7.0 起由 v2 适配器合成)。
914

docs/refactor/WIRE_FORMAT_V2_PLAN.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@
2525
| S10b | 两级 in-flight 合流 + 受限 TTL 微缓存 || 2026-07-15 | (未提交) | `server/lib/v2/singleflight.js`:一级按 sessionDir 共享 Pass A、二级按 (dir,limit,before) 共享全窗 Pass B;500ms TTL 微缓存**只服务只读历史面**(/api/local-log、IM 弹窗),**绝不服务 /events live-attach**(陈旧窗口会放大冷加载→attach 广播丢失窗口,F5b)。readTailEntries 默认 cached=true、streamRawEntriesAsync 默认 cached=false。测试 test/v2-singleflight.test.js(Pass A 计数=1 断言、live-attach 拿不到缓存断言) |
2626
| S10c | 收口 limit=0 无界面 || 2026-07-15 | (未提交) | 三个 limit=0 路径(/api/local-log 全量、workspaces 重载广播、/api/requests)已随 S10a 流式 Pass B 自动治住——Pass A 只留轻量 descriptor、Pass B 逐条流式,683MB 全量流实测常驻 ~2MB(**手工实测,非 CI 守卫**;CI 用合成夹具锁全流式增量输出正确性)。无需人为截断(原计划的 DEFAULT_EVENTS_LIMIT 前提「仍全量物化」已不成立,/api/requests 保持完整流式输出)。**已知可接受差异**:无界流按合成序发射,重复 ts\|url 时幸存者位置晚于历史首现序——客户端序不敏感,详见 WIRE_FORMAT_V2.md §11。 |
2727

28+
| B | brotli 传输压缩(br\|identity 协商 + 逐事件 flush,wire-compress.js 单缝) || 2026-07-16 | c8c6c25 | 实测冷载 20.8×/live 80×;含评审 6 项修复(encoder 感知背压 awaitWireDrain、q=0 拒绝、/api/requests try/catch、no-flush 整流、双分支 Vary、踢除时 destroy encoder)。逃生舱 CCV_WIRE_COMPRESSION=off。**已知边界:浏览器仅在 HTTPS/localhost 发 br——远程明文 http 回退 identity(由 V3 补位)** |
29+
| V3.S1 | `/api/v2-entry` 单条详情(target+prevMain,逐成员 checkpoint 提升) || 2026-07-16 | c94c28e | promoteKeys 物化模式;UUID/basename 双寻址(先 resolve 再 validate);test/v2-entry-endpoint.test.js |
30+
| V3.S2 | `v2_requests` 元数据行通道(journal 折叠 + 有界 Pass B typeTag/cacheLoss) || 2026-07-16 | add9835 | classifyRequest 服务端复用(requestType 链补 .js 扩展名单源);三处有意偏移测试钉死;旗标 deps.wireV3 启动读一次 + server_config 广播 |
31+
| V3.S3 | 旗标前端:列表自行渲染 + 按需详情 || 2026-07-16 | a43ffb6 | _listSource() 单缝(桌面/移动共用);componentDidUpdate 驱动 fetch + abort;entryCache protocolVersion 单门(不 bump DB_VERSION——暗着陆保命) |
32+
| V3.S4 | 原生 conv/responses 行转发(冷窗 + live 三钩子) || 2026-07-16 | 43fb6f1 | 冷载自"最近 snapshot ≤ 窗口起点";**架构改道(用户批准):废弃摘录帧方案** |
33+
| V3.S5 | 客户端组装器;旗标线缆停发全量 entry || 2026-07-16 | 08bac13 | v3Assembler 重建 v1 形状 entry 喂现有管线(parity oracle 钉死);深读消费者走 deepRequests;live 停发 data: 帧(kv/context 侧事件保留)。实测冷载明文 176.8→42.5MB(÷4.2) |
34+
| V3.S6 | 翻转默认 + 文档收尾 || 2026-07-16 | (本次提交) | CCV_WIRE_V3 默认开、=0/off 逃生舱;WIRE_FORMAT_V3.md 新建;entry-slim **保留**(方案 B 下 v1 遗留/逃生舱仍需,v3 路径天然闲置——取代原"退役"项);legacy live UI 路径留一个发布周期后再删 |
35+
2836
**恢复协议**:新 session 开始 → 读记忆 `wire-format-v2-progress.md` 指针 → 读本表找到第一个非 ✅ 步骤 → **先跑上一完成步的 named tests 确认仍绿** → 再开工。步骤内中断:状态记 🔄 + 在"下一步动作备注"写明断点。
2937

3038
**全系列评审 P3 backlog(2026-07-13 六人团评审,P1+P2 已全部修复)**

docs/refactor/WIRE_FORMAT_V3.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Wire Format v3 — metadata rows + native chat wire (server→client)
2+
3+
Status: **default ON since V3.S6** (escape hatch `CCV_WIRE_V3=0`). Applies to
4+
v2 session sources on `/events` only; v1 legacy FILES always use the legacy
5+
full-entry pipeline (permanent escape hatch, unrelated to the flag).
6+
7+
Motivation (measured, 68MB/3830-request real session): the legacy wire
8+
re-synthesized full v1-shape entries — cold load 176.8MB plaintext per tab,
9+
live channel re-broadcast the full accumulated history twice per mainAgent
10+
turn (3.7GB per client per session). v3 sends journal-fold metadata rows +
11+
the raw stored conv/responses lines; the client rebuilds entries locally.
12+
Cold plaintext ÷4.2 (42.5MB), live per-turn = delta-sized, client JSON parse
13+
÷4.2. Under brotli (axis B) both wires converge near the same entropy floor
14+
(~8-9MB cold), so v3's wins are the remote-plaintext path, client parse, and
15+
the live channel structure — not localhost br bytes.
16+
17+
## 1. Flag & negotiation
18+
19+
- `CCV_WIRE_V3` read ONCE at server startup into `deps.wireV3`
20+
(server/server.js); `V2LiveFeed` receives it as a constructor param.
21+
- Client learns it from the `server_config` SSE frame (`wireV3: bool`),
22+
stored synchronously as `this._wireV3` (AppBase) — the frame precedes
23+
`clients.push(res)`, so every connection knows before any data frame.
24+
- All new frames ride the axis-B compression seam automatically
25+
(`sseWrite`/`sendEventToClients`); never hand-roll `res.write` for them.
26+
27+
## 2. Frames (flagged cold load, in wire order)
28+
29+
1. `load_start``{total, incremental, v3Bytes, hasMore?, oldestTs?}`
30+
(total = rows window count; `v3Bytes` = exact sum of the upcoming v3 frame
31+
payload lengths in UTF-16 code units — the client's byte meter counts
32+
`event.data.length` on the same unit, so received/total is exact).
33+
2. `v2_requests``{rows, totalCount, hasMore, oldestTs}`. Row schema
34+
(journal fold; `server/lib/v2/meta-rows.js`):
35+
`{seq, sessionId, timestamp, url, method, conv?, evt?, kind, mainAgent,
36+
teammate?, model?, proxyUrl?, status?, duration?, usage?, inProgress,
37+
typeTag, cacheLoss}` — top-level `timestamp`/`url` keep the since-cursor
38+
and `timestamp|url` dedup identities; `usage` is mapped to the client
39+
shape (`input_tokens`…); `typeTag {type, subType}` is computed with the
40+
client's own `classifyRequest` (shared module, extensioned imports);
41+
`cacheLoss {reason, reasons}` from the bounded Pass B.
42+
3. `v3_conv``{sessionId, channel, lines:[...]}` raw conv-store lines
43+
(`snapshot|append|ctl`) from the channel's LAST snapshot at-or-before the
44+
window's oldest seq (the assembler's replay baseline).
45+
4. `v3_resp``{sessionId, lines:[...]}` raw responses lines for exactly
46+
the window member seqs.
47+
5. `load_end` — the client assembler materializes the window here.
48+
49+
Legacy `load_chunk` frames are NOT sent for flagged v2 sources. An
50+
incremental reconnect (`?since=` present) sends a since-scoped delta window
51+
(the `v2_requests` frame carries `incremental:true` and the client UPSERTS
52+
instead of resetting); a full cold frame (reset) also resets the client's
53+
assembler / live-dedup state. Live: `v2_requests_delta` (one row per emitted
54+
item; a correction re-sends the row when the NEXT request changes its
55+
Preflight/Plan classification), plus single-line `v3_conv`/`v3_resp` frames;
56+
the full-entry `data:` broadcast is suppressed (kv_cache_content /
57+
context_window side events unchanged — cold values rebuild from the newest
58+
≤3 completed mainAgent rows, mirroring the legacy scan-ring fallback depth).
59+
`server_config` additionally carries `build` (server version): a tab whose
60+
bundle predates a server upgrade reloads itself on reconnect mismatch.
61+
62+
## 3. On-demand detail — `GET /api/v2-entry`
63+
64+
`?file=v2:<project>/<dirToken>&seq=N[&sid=<uuid>]``{entry, prevMain}`.
65+
`dirToken` may be the dir basename or the bare session UUID (resolved via
66+
`resolveSessionDirName` BEFORE `validateLogPath`); `sid` disambiguates
67+
teammate rows (the fold runs over the leader dir). Mid-session main deltas
68+
are promoted to full replayed state (per-member checkpoint mode in
69+
`materializeV2Window`); `prevMain` is the preceding mainAgent entry (Body
70+
Diff / Context tab). Compressed via `sseHead({flush:false})`. Unknown seq /
71+
rename races → 404; the client surfaces an error state with a Retry action.
72+
73+
## 4. Client assembler (`src/utils/v3Assembler.js`)
74+
75+
Replays conv lines per `(sessionId, channel)` (snapshot → reset, append →
76+
concat, ctl replace-tail → swap last; ctl compact is a state no-op) and
77+
builds v1-shape entries in the shape the legacy live wire delivered AFTER
78+
server-side reconstruction: full accumulated `body.messages` (shared refs),
79+
`_seq`/`_seqEpoch("v2:<uuid>")`/`_totalMessageCount`, `_isCheckpoint` on
80+
snapshot rows, placeholder entries for `inProgress` rows, `response.body`
81+
from the responses line. **No `body.tools`/`body.system`** — rows carry
82+
classification, the detail view fetches full bodies. Entries feed the
83+
EXISTING ingest (`_ingestLiveEntry` seam / `_chunkedEntries` at load_end):
84+
merge guards, ChatView, team modal, tool-result maps keep exact semantics
85+
(oracle: `test/v3-assembler.test.js` asserts field parity vs the legacy
86+
client-reconstructed stream). The request list renders adapted rows
87+
(`_listSource()`); deep consumers read the assembled entries
88+
(`deepRequests`).
89+
90+
## 5. Deliberate divergences (test-pinned)
91+
92+
- Row membership = journal fold (superset: conv-gapped crash-orphans get a
93+
row; their detail fetch 404s gracefully).
94+
- `mainAgent` is kind-derived (`kind==='main' && !meta.leader`) — the
95+
mainAgentRing semantics; "main-looking" sub bodies are no longer
96+
mis-tagged.
97+
- cacheLoss `ttl` actually fires (legacy client computed the gap as
98+
string-minus-string NaN).
99+
- Live rows' `typeTag` may arrive null then be corrected (lookahead).
100+
101+
## 6. Retained surfaces (NOT flagged)
102+
103+
- `/api/local-log` (log-viewer modal + IM conversation modal) still serves
104+
v1-shape adapter output — windowed, on-demand, low-volume.
105+
- Downloads / workspace previews / `ccv verify` / converter: v1-shape
106+
adapter output, byte-true invariant untouched (v3 never reaches into the
107+
adapter's emit shape; `readV2SingleEntry` composes existing machinery).
108+
- `src/utils/entry-slim.js` is RETAINED (v1 legacy files + flag-off wire
109+
still deliver self-contained entries); on the v3 path it is naturally idle
110+
(assembled entries carry no tools/system). The original S6 "retire
111+
entry-slim" item is superseded by the option-B assembler architecture.
112+
113+
## 7. Backlog
114+
115+
- Cold conv window: synthesize a snapshot at the window start server-side
116+
(today: replay from the last stored snapshot, which can drag a long tail).
117+
- `/api/v2-search` (server-side streaming search over `iterateV2Items`,
118+
discipline like `verify.js`): interface reserved, NOT in this cycle.
119+
- Remove the legacy full-entry live UI path once the `CCV_WIRE_V3=0` escape
120+
hatch has survived a release cycle unused.
121+
122+
Cross-refs: `docs/refactor/WIRE_FORMAT_V2.md` (storage), `docs/WIRE_FORMAT.md`
123+
(v1 wire + §3.7 double-layer invariant — still governs the legacy channel).

0 commit comments

Comments
 (0)