已合并
[feat] 数据湖链路平台化:中性命名统一(env 前缀 breaking)+ 配置单源化 + 双栈单一引擎 + 信封双名 #49
[feat] 数据湖链路平台化:中性命名统一(env 前缀 breaking)+ 配置单源化 + 双栈单一引擎 + 信封双名 #49
已合并
duanbingzhe创建于 7 天前
共 127 个文件变更+3612-3019
@@ -2,6 +2,11 @@
2 2 
3格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。3格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。
4 4 
5+## [Unreleased]
6+ 
7+### Changed
8+- 【信封双名 + 规范文档引用更名(平台化 P3,与 insight 同步)】捕获扩展字段信封写侧双名过渡:同一信封对象挂新键 `x_lake` + 旧键(三个 emitter 全部写点),读侧(meta.json 回读×2)新键优先兜底旧键——老版本 insight 读方零损失、湖内存量数据零迁移;下个大版本退役旧键。格式规范文档引用同步更名。门禁:ut 173 / e2e 10
9+ 
5## [v0.1.8] - 2026-09-2210## [v0.1.8] - 2026-09-22
6 11 
7### Added12### Added
@@ -27,11 +27,11 @@ import path from 'node:path';
27import type { ProxyRecord, Protocol, AnthropicContentBlock, AnthropicUsage, OpenAIUsage } from './types';27import type { ProxyRecord, Protocol, AnthropicContentBlock, AnthropicUsage, OpenAIUsage } from './types';
28import { sessionFilePath, sessionMetaPath, subagentFilePath, subagentMetaPath, appendClaudeLine, writeMeta, PROXY_DIR } from './writer';28import { sessionFilePath, sessionMetaPath, subagentFilePath, subagentMetaPath, appendClaudeLine, writeMeta, PROXY_DIR } from './writer';
29 29 
30-// x_cannbay 命名空间(docs/cannbay-schema-spec.md):claude 原生字段是冻结30+// x_cannbay 命名空间(docs/lake-schema-spec.md):claude 原生字段是冻结
31// 信封,扩展数据进带 (schema,version) 声明的口袋。双轨期 legacy 顶层字段31// 信封,扩展数据进带 (schema,version) 声明的口袋。双轨期 legacy 顶层字段
32// (source/duration_ms/system/tools/ttftMs/stopReason/version/deduped/32// (source/duration_ms/system/tools/ttftMs/stopReason/version/deduped/
33// originalChars)并存写 —— 读方一律 x_cannbay 优先。33// originalChars)并存写 —— 读方一律 x_cannbay 优先。
34-function xCannbay(schema: string, data: Record<string, unknown>) {34+function xLake(schema: string, data: Record<string, unknown>) {
35 return { schema, version: 1, data };35 return { schema, version: 1, data };
36}36}
37 37 
@@ -218,7 +218,13 @@ export function emit(rec: ProxyRecord): void {
218 if (!sessionMetaWritten.has(sid)) {218 if (!sessionMetaWritten.has(sid)) {
219 sessionMetaWritten.add(sid);219 sessionMetaWritten.add(sid);
220 writeMeta(sessionMetaPath(sid), {220 writeMeta(sessionMetaPath(sid), {
221- x_cannbay: xCannbay('cc-session-meta', {221+ x_lake: xLake('cc-session-meta', {
222+ producer: 'cpx',
223+ framework: rec.protocol === 'openai' ? 'opencode' : 'claude-code',
224+ protocol: rec.protocol,
225+ ccVersion: ccVersionFromSystem(body.system),
226+ sid,
227+ }), x_cannbay: xLake('cc-session-meta', {
222 producer: 'cpx',228 producer: 'cpx',
223 framework: rec.protocol === 'openai' ? 'opencode' : 'claude-code',229 framework: rec.protocol === 'openai' ? 'opencode' : 'claude-code',
224 protocol: rec.protocol,230 protocol: rec.protocol,
@@ -239,7 +245,12 @@ export function emit(rec: ProxyRecord): void {
239 toolUseId: meta?.toolUseId ?? null,245 toolUseId: meta?.toolUseId ?? null,
240 name: meta?.name ?? null,246 name: meta?.name ?? null,
241 agentType: meta?.type ?? null,247 agentType: meta?.type ?? null,
242- x_cannbay: xCannbay('cc-subagent-meta', {248+ x_lake: xLake('cc-subagent-meta', {
249+ toolUseId: meta?.toolUseId ?? null,
250+ name: meta?.name ?? null,
251+ agentType: meta?.type ?? null,
252+ subagentSessionId: subId,
253+ }), x_cannbay: xLake('cc-subagent-meta', {
243 toolUseId: meta?.toolUseId ?? null,254 toolUseId: meta?.toolUseId ?? null,
244 name: meta?.name ?? null,255 name: meta?.name ?? null,
245 agentType: meta?.type ?? null,256 agentType: meta?.type ?? null,
@@ -298,7 +309,11 @@ export function emit(rec: ProxyRecord): void {
298 deduped: true,309 deduped: true,
299 originalChars: injectionText!.length,310 originalChars: injectionText!.length,
300 source: proxySourceMarker(rec.protocol),311 source: proxySourceMarker(rec.protocol),
301- x_cannbay: xCannbay('cc-wire-input', {312+ x_lake: xLake('cc-wire-input', {
313+ roundIndex: roundCount.get(contextKey) ?? 0,
314+ kind: 'dedup-placeholder',
315+ dedup: { originalChars: injectionText!.length, fingerprint },
316+ }), x_cannbay: xLake('cc-wire-input', {
302 roundIndex: roundCount.get(contextKey) ?? 0,317 roundIndex: roundCount.get(contextKey) ?? 0,
303 kind: 'dedup-placeholder',318 kind: 'dedup-placeholder',
304 dedup: { originalChars: injectionText!.length, fingerprint },319 dedup: { originalChars: injectionText!.length, fingerprint },
@@ -314,7 +329,10 @@ export function emit(rec: ProxyRecord): void {
314 message: { role: m.role, content: m.content ?? '' },329 message: { role: m.role, content: m.content ?? '' },
315 timestamp: toISO(rec.receivedAt),330 timestamp: toISO(rec.receivedAt),
316 source: proxySourceMarker(rec.protocol),331 source: proxySourceMarker(rec.protocol),
317- x_cannbay: xCannbay('cc-wire-input', {332+ x_lake: xLake('cc-wire-input', {
333+ roundIndex: roundCount.get(contextKey) ?? 0,
334+ kind: wireInputKind(m),
335+ }), x_cannbay: xLake('cc-wire-input', {
318 roundIndex: roundCount.get(contextKey) ?? 0,336 roundIndex: roundCount.get(contextKey) ?? 0,
319 kind: wireInputKind(m),337 kind: wireInputKind(m),
320 }),338 }),
@@ -333,7 +351,7 @@ export function emit(rec: ProxyRecord): void {
333 // WITHOUT an OCP-bending post-write) + `stopReason` (adapter reads →351 // WITHOUT an OCP-bending post-write) + `stopReason` (adapter reads →
334 // finish_reason → Turn.finishReason). `ttftMs` has no pipeline field, so it352 // finish_reason → Turn.finishReason). `ttftMs` has no pipeline field, so it
335 // stays a pure extension field (declared in x_cannbay cc-wire-round; today353 // stays a pure extension field (declared in x_cannbay cc-wire-round; today
336- // it only round-trips via cannbay2 cc-db-turn export/patch).354+ // it only round-trips via lake cc-db-turn export/patch).
337 // x_cannbay(cc-wire-round)是以上全部信息的声明式权威源(双轨并存,读方优先),355 // x_cannbay(cc-wire-round)是以上全部信息的声明式权威源(双轨并存,读方优先),
338 // 并新增 legacy 通道没有的:roundIndex / requestParams / status。356 // 并新增 legacy 通道没有的:roundIndex / requestParams / status。
339 const contentBlocks = Array.isArray(rec.response.content) ? rec.response.content as AnthropicContentBlock[] : [];357 const contentBlocks = Array.isArray(rec.response.content) ? rec.response.content as AnthropicContentBlock[] : [];
@@ -356,7 +374,22 @@ export function emit(rec: ProxyRecord): void {
356 stopReason: rec.response.stop_reason ?? undefined,374 stopReason: rec.response.stop_reason ?? undefined,
357 ttftMs: rec.ttftMs ?? undefined,375 ttftMs: rec.ttftMs ?? undefined,
358 source: proxySourceMarker(rec.protocol),376 source: proxySourceMarker(rec.protocol),
359- x_cannbay: xCannbay('cc-wire-round', {377+ x_lake: xLake('cc-wire-round', {
378+ roundIndex,
379+ protocol: rec.protocol,
380+ system: body.system ?? undefined,
381+ tools: Array.isArray(body.tools) ? body.tools : undefined,
382+ requestParams: {
383+ temperature: body.temperature,
384+ maxTokens: body.max_tokens,
385+ model: rec.request.model,
386+ },
387+ latencyMs: rec.latencyMs,
388+ ttftMs: rec.ttftMs ?? undefined,
389+ stopReason: rec.response.stop_reason ?? undefined,
390+ status: rec.response.status,
391+ ccVersion: ccVersionFromSystem(body.system) ?? undefined,
392+ }), x_cannbay: xLake('cc-wire-round', {
360 roundIndex,393 roundIndex,
361 protocol: rec.protocol,394 protocol: rec.protocol,
362 system: body.system ?? undefined,395 system: body.system ?? undefined,
@@ -561,7 +561,8 @@ function readSessionMetaFor(stem: string, sid: string): { framework: string | nu
561 ];561 ];
562 for (const c of candidates) {562 for (const c of candidates) {
563 try {563 try {
564- const xb = JSON.parse(fs.readFileSync(c, 'utf-8'))?.x_cannbay;564+ const xb0 = JSON.parse(fs.readFileSync(c, 'utf-8'));
565+ const xb = xb0?.x_lake ?? xb0?.x_cannbay;
565 if (xb?.schema === 'cc-session-meta' && xb.data && typeof xb.data === 'object') {566 if (xb?.schema === 'cc-session-meta' && xb.data && typeof xb.data === 'object') {
566 return {567 return {
567 framework: typeof xb.data.framework === 'string' ? xb.data.framework : null,568 framework: typeof xb.data.framework === 'string' ? xb.data.framework : null,
@@ -880,13 +881,13 @@ async function main(): Promise<void> {
880 // subagents 压缩为 .jsonl.7z —— 在 normalize/import 之前,sourcePath 一次881 // subagents 压缩为 .jsonl.7z —— 在 normalize/import 之前,sourcePath 一次
881 // 定型为 .7z。同时给伴生 cc-session-meta 打 governed 标记(spec §4.4):882 // 定型为 .7z。同时给伴生 cc-session-meta 打 governed 标记(spec §4.4):
882 // 字节流已经 cpx 落盘咽喉 redactor 全量清洗(server.ts dispatchEmit),883 // 字节流已经 cpx 落盘咽喉 redactor 全量清洗(server.ts dispatchEmit),
883- // cannbay2 上传侧据此走直传快路径(免解压重洗重压)。884+ // lake 上传侧据此走直传快路径(免解压重洗重压)。
884 const markGoverned = (sidRaw: string): void => {885 const markGoverned = (sidRaw: string): void => {
885 try {886 try {
886 const metaPath = sessionMetaPath(sidRaw);887 const metaPath = sessionMetaPath(sidRaw);
887 if (!fs.existsSync(metaPath)) return;888 if (!fs.existsSync(metaPath)) return;
888 const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));889 const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
889- const xb = meta?.x_cannbay;890+ const xb = meta?.x_lake ?? meta?.x_cannbay;
890 if (xb?.schema === 'cc-session-meta' && xb.data && xb.data.producer === 'cpx') {891 if (xb?.schema === 'cc-session-meta' && xb.data && xb.data.producer === 'cpx') {
891 xb.data.governed = true;892 xb.data.governed = true;
892 fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');893 fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
@@ -44,7 +44,7 @@ interface ResponseRequestBody {
44 stream?: boolean;44 stream?: boolean;
45}45}
46 46 
47-function xCannbay(schema: string, data: Record<string, unknown>) {47+function xLake(schema: string, data: Record<string, unknown>) {
48 return { schema, version: 1, data };48 return { schema, version: 1, data };
49}49}
50 50 
@@ -61,7 +61,7 @@ function mapUsage(usage: AnthropicUsage | OpenAIUsage | null): AnthropicUsage |
61 return usage as AnthropicUsage;61 return usage as AnthropicUsage;
62}62}
63 63 
64-function convertInputItem(item: ResponseInputItem): { type: string; message: { role: string; content: string | AnthropicContentBlock[] }; source: string; x_cannbay: { schema: string; version: number; data: { roundIndex: number; kind: string } } } | null {64+function convertInputItem(item: ResponseInputItem): { type: string; message: { role: string; content: string | AnthropicContentBlock[] }; source: string; x_lake: { schema: string; version: number; data: { roundIndex: number; kind: string } }; x_cannbay: { schema: string; version: number; data: { roundIndex: number; kind: string } } } | null {
65 if (item.type === 'message') {65 if (item.type === 'message') {
66 const role = item.role ?? 'user';66 const role = item.role ?? 'user';
67 if (role === 'assistant' || role === 'system' || role === 'developer') return null;67 if (role === 'assistant' || role === 'system' || role === 'developer') return null;
@@ -72,7 +72,7 @@ function convertInputItem(item: ResponseInputItem): { type: string; message: { r
72 type: 'user',72 type: 'user',
73 message: { role: 'user', content: content || '' },73 message: { role: 'user', content: content || '' },
74 source: 'codex-proxy',74 source: 'codex-proxy',
75- x_cannbay: xCannbay('cc-wire-input', { roundIndex: 0, kind: 'user' }),75+ x_lake: xLake('cc-wire-input', { roundIndex: 0, kind: 'user' }), x_cannbay: xLake('cc-wire-input', { roundIndex: 0, kind: 'user' }),
76 };76 };
77 }77 }
78 if (item.type === 'function_call_output') {78 if (item.type === 'function_call_output') {
@@ -83,7 +83,7 @@ function convertInputItem(item: ResponseInputItem): { type: string; message: { r
83 content: [{ type: 'tool_result', tool_use_id: item.call_id ?? '', content: item.output ?? '' }],83 content: [{ type: 'tool_result', tool_use_id: item.call_id ?? '', content: item.output ?? '' }],
84 },84 },
85 source: 'codex-proxy',85 source: 'codex-proxy',
86- x_cannbay: xCannbay('cc-wire-input', { roundIndex: 0, kind: 'tool-result' }),86+ x_lake: xLake('cc-wire-input', { roundIndex: 0, kind: 'tool-result' }), x_cannbay: xLake('cc-wire-input', { roundIndex: 0, kind: 'tool-result' }),
87 };87 };
88 }88 }
89 return null;89 return null;
@@ -98,7 +98,12 @@ export function emit(rec: ProxyRecord): void {
98 if (!sessionMetaWritten.has(sid)) {98 if (!sessionMetaWritten.has(sid)) {
99 sessionMetaWritten.add(sid);99 sessionMetaWritten.add(sid);
100 writeMeta(sessionMetaPath(sid), {100 writeMeta(sessionMetaPath(sid), {
101- x_cannbay: xCannbay('cc-session-meta', {101+ x_lake: xLake('cc-session-meta', {
102+ producer: 'cpx',
103+ framework: 'codex',
104+ protocol: 'responses',
105+ sid,
106+ }), x_cannbay: xLake('cc-session-meta', {
102 producer: 'cpx',107 producer: 'cpx',
103 framework: 'codex',108 framework: 'codex',
104 protocol: 'responses',109 protocol: 'responses',
@@ -113,7 +118,7 @@ export function emit(rec: ProxyRecord): void {
113 for (let i = prev; i < items.length; i++) {118 for (let i = prev; i < items.length; i++) {
114 const line = convertInputItem(items[i]);119 const line = convertInputItem(items[i]);
115 if (line) {120 if (line) {
116- (line.x_cannbay as { data: { roundIndex: number } }).data.roundIndex = roundIndex;121+ (line.x_lake as { data: { roundIndex: number } }).data.roundIndex = roundIndex;
117 appendClaudeLine(sessionFilePath(sid), { ...line, timestamp: toISO(rec.receivedAt) });122 appendClaudeLine(sessionFilePath(sid), { ...line, timestamp: toISO(rec.receivedAt) });
118 }123 }
119 }124 }
@@ -135,7 +140,17 @@ export function emit(rec: ProxyRecord): void {
135 system: body.instructions ?? undefined,140 system: body.instructions ?? undefined,
136 tools: Array.isArray(body.tools) ? body.tools : undefined,141 tools: Array.isArray(body.tools) ? body.tools : undefined,
137 source: proxySourceMarker(rec.protocol),142 source: proxySourceMarker(rec.protocol),
138- x_cannbay: xCannbay('cc-wire-round', {143+ x_lake: xLake('cc-wire-round', {
144+ roundIndex,
145+ protocol: 'responses',
146+ system: body.instructions ?? undefined,
147+ tools: Array.isArray(body.tools) ? body.tools : undefined,
148+ requestParams: { model: body.model ?? rec.request.model },
149+ latencyMs: rec.latencyMs,
150+ ttftMs: rec.ttftMs ?? undefined,
151+ stopReason: rec.response.stop_reason ?? undefined,
152+ status: rec.response.status,
153+ }), x_cannbay: xLake('cc-wire-round', {
139 roundIndex,154 roundIndex,
140 protocol: 'responses',155 protocol: 'responses',
141 system: body.instructions ?? undefined,156 system: body.instructions ?? undefined,
@@ -63,9 +63,9 @@ const subMetaWritten = new Set<string>(); // subIds that already got a meta.json
63// from main agent responses so child-session meta.json can link back to the spawn.63// from main agent responses so child-session meta.json can link back to the spawn.
64const taskDispatch = new Map<string, { toolUseId: string | null; name: string | null; agentType: string | null }>();64const taskDispatch = new Map<string, { toolUseId: string | null; name: string | null; agentType: string | null }>();
65 65 
66-// x_cannbay 命名空间(docs/cannbay-schema-spec.md):信封外一切扩展的声明式66+// x_cannbay 命名空间(docs/lake-schema-spec.md):信封外一切扩展的声明式
67// 权威源,双轨期与 legacy 顶层字段并存写 —— 读方一律 x_cannbay 优先。67// 权威源,双轨期与 legacy 顶层字段并存写 —— 读方一律 x_cannbay 优先。
68-function xCannbay(schema: string, data: Record<string, unknown>) {68+function xLake(schema: string, data: Record<string, unknown>) {
69 return { schema, version: 1, data };69 return { schema, version: 1, data };
70}70}
71 71 
@@ -193,7 +193,13 @@ export function emit(rec: ProxyRecord): void {
193 sessionMetaWritten.add(mainSid);193 sessionMetaWritten.add(mainSid);
194 const agent = agentFrameworkOf(rec.userAgent);194 const agent = agentFrameworkOf(rec.userAgent);
195 writeMeta(sessionMetaPath(mainSid), {195 writeMeta(sessionMetaPath(mainSid), {
196- x_cannbay: xCannbay('cc-session-meta', {196+ x_lake: xLake('cc-session-meta', {
197+ producer: 'cpx',
198+ framework: agent.framework,
199+ protocol: 'openai',
200+ ...(agent.framework === 'cannbot' ? { ccVersion: agentVersionOf(rec.userAgent) } : {}),
201+ sid: mainSid,
202+ }), x_cannbay: xLake('cc-session-meta', {
197 producer: 'cpx',203 producer: 'cpx',
198 framework: agent.framework,204 framework: agent.framework,
199 protocol: 'openai',205 protocol: 'openai',
@@ -260,7 +266,10 @@ export function emit(rec: ProxyRecord): void {
260 },266 },
261 timestamp: toISO(rec.receivedAt),267 timestamp: toISO(rec.receivedAt),
262 source: agent.marker,268 source: agent.marker,
263- x_cannbay: xCannbay('cc-wire-input', {269+ x_lake: xLake('cc-wire-input', {
270+ roundIndex: roundCount.get(contextKey) ?? 0,
271+ kind: 'tool-result',
272+ }), x_cannbay: xLake('cc-wire-input', {
264 roundIndex: roundCount.get(contextKey) ?? 0,273 roundIndex: roundCount.get(contextKey) ?? 0,
265 kind: 'tool-result',274 kind: 'tool-result',
266 }),275 }),
@@ -271,7 +280,10 @@ export function emit(rec: ProxyRecord): void {
271 message: { role: m.role, content: convertUserContent(m.content) },280 message: { role: m.role, content: convertUserContent(m.content) },
272 timestamp: toISO(rec.receivedAt),281 timestamp: toISO(rec.receivedAt),
273 source: agent.marker,282 source: agent.marker,
274- x_cannbay: xCannbay('cc-wire-input', {283+ x_lake: xLake('cc-wire-input', {
284+ roundIndex: roundCount.get(contextKey) ?? 0,
285+ kind: wireInputKind(m),
286+ }), x_cannbay: xLake('cc-wire-input', {
275 roundIndex: roundCount.get(contextKey) ?? 0,287 roundIndex: roundCount.get(contextKey) ?? 0,
276 kind: wireInputKind(m),288 kind: wireInputKind(m),
277 }),289 }),
@@ -298,7 +310,12 @@ export function emit(rec: ProxyRecord): void {
298 toolUseId: meta.toolUseId,310 toolUseId: meta.toolUseId,
299 name: meta.name,311 name: meta.name,
300 agentType: meta.agentType,312 agentType: meta.agentType,
301- x_cannbay: xCannbay('cc-subagent-meta', {313+ x_lake: xLake('cc-subagent-meta', {
314+ toolUseId: meta.toolUseId,
315+ name: meta.name,
316+ agentType: meta.agentType,
317+ subagentSessionId: subId,
318+ }), x_cannbay: xLake('cc-subagent-meta', {
302 toolUseId: meta.toolUseId,319 toolUseId: meta.toolUseId,
303 name: meta.name,320 name: meta.name,
304 agentType: meta.agentType,321 agentType: meta.agentType,
@@ -345,7 +362,26 @@ export function emit(rec: ProxyRecord): void {
345 // x_cannbay(cc-wire-round)是以上全部信息的声明式权威源(spec §6 双轨362 // x_cannbay(cc-wire-round)是以上全部信息的声明式权威源(spec §6 双轨
346 // 并存,读方优先),并新增 legacy 通道没有的:roundIndex / requestParams /363 // 并存,读方优先),并新增 legacy 通道没有的:roundIndex / requestParams /
347 // status / protocol / reasoningTokens。364 // status / protocol / reasoningTokens。
348- x_cannbay: xCannbay('cc-wire-round', {365+ x_lake: xLake('cc-wire-round', {
366+ roundIndex,
367+ protocol: 'openai',
368+ system: systemText ?? undefined,
369+ tools,
370+ requestParams: {
371+ temperature: body.temperature,
372+ maxTokens: body.max_tokens,
373+ model: rec.request.model,
374+ },
375+ latencyMs: rec.latencyMs,
376+ ttftMs: rec.ttftMs ?? undefined,
377+ stopReason: rec.response.stop_reason ?? undefined,
378+ status: rec.response.status,
379+ // 思考计费 token(openai 重组器自 usage.completion_tokens_details 映射):
380+ // insight claude-jsonl 对 cc-wire-round v1 既有 reasoningTokens 读取通道,
381+ // 零 insight 改动恢复 Turn reasoning tokens。undefined 时 JSON 序列化自然
382+ // 省略该键(anthropic 协议路径无此字段)。
383+ reasoningTokens: usage?.reasoning_tokens,
384+ }), x_cannbay: xLake('cc-wire-round', {
349 roundIndex,385 roundIndex,
350 protocol: 'openai',386 protocol: 'openai',
351 system: systemText ?? undefined,387 system: systemText ?? undefined,
@@ -38,7 +38,7 @@ export function sessionFilePath(sid: string): string {
38 return path.join(dir(), `${captureStem(sid)}.jsonl`);38 return path.join(dir(), `${captureStem(sid)}.jsonl`);
39}39}
40 40 
41-// Main-session meta(cc-session-meta,schema 见 docs/cannbay-schema-spec.md):41+// Main-session meta(cc-session-meta,schema 见 docs/lake-schema-spec.md):
42// producer/framework/protocol/ccVersion 等文件级声明,紧邻主捕获文件。42// producer/framework/protocol/ccVersion 等文件级声明,紧邻主捕获文件。
43export function sessionMetaPath(sid: string): string {43export function sessionMetaPath(sid: string): string {
44 return path.join(dir(), `${captureStem(sid)}.meta.json`);44 return path.join(dir(), `${captureStem(sid)}.meta.json`);
@@ -61,7 +61,7 @@ Session detail page (`session/[taskId]/`) is `"use client"` with 10 tabs
61- **AI analysis**: `src/lib/ai/analyzer.ts` — only OpenAI-compatible `/chat/completions`, config in localStorage only61- **AI analysis**: `src/lib/ai/analyzer.ts` — only OpenAI-compatible `/chat/completions`, config in localStorage only
62- **CLI client**: `src/cli/client.ts` — InsightClient with 17 methods across `/api/observe/*`, `/api/ingest/*`, `/api/ai/*`62- **CLI client**: `src/cli/client.ts` — InsightClient with 17 methods across `/api/observe/*`, `/api/ingest/*`, `/api/ai/*`
63- **CLI types**: `src/cli/types.ts` — `Api`-prefixed interfaces to distinguish from `src/lib/shared/types.ts`63- **CLI types**: `src/cli/types.ts` — `Api`-prefixed interfaces to distinguish from `src/lib/shared/types.ts`
64-- **CANNBay v2**: `src/lib/cannbay2/`(mirror 治理/导出/编排)— 设计见 `docs/design/cannbay-v2/design.md`64+- **Lake v2(数据湖引擎)**: `src/lib/lake`(mirror 治理/导出/编排)— 设计见 `docs/design/lake-v2/design.md`
65 65 
66### CLI Frontend66### CLI Frontend
67 67 
@@ -153,10 +153,10 @@ proxy(cpx 捕获)是 insight 的扩展来源,不是核心管线的一部
153 153 
154## Constraints & Gotchas154## Constraints & Gotchas
155 155 
156-- **cannbay2 镜像的 git 命令必须走 `runGit`**(mirror.ts):其 per-command 配置中和 lfs filter(`filter.lfs.process= / smudge=cat / clean=cat / required=false`)+ `push --no-verify`。数据仓 `.gitattributes` 历史上带 `*.gz filter=lfs`,装 git-lfs 的客户机会在 checkout(孤儿指针 smudge 403)与 add(clean 指针化)两条路上炸;绕过 runGit 直拼 git 命令会重新暴露(IT:`tests/cannbay2-lfs-immunity.test.ts`)156+- **lake 镜像的 git 命令必须走 `runGit`**(mirror.ts):其 per-command 配置中和 lfs filter(`filter.lfs.process= / smudge=cat / clean=cat / required=false`)+ `push --no-verify`。数据仓 `.gitattributes` 历史上带 `*.gz filter=lfs`,装 git-lfs 的客户机会在 checkout(孤儿指针 smudge 403)与 add(clean 指针化)两条路上炸;绕过 runGit 直拼 git 命令会重新暴露(IT:`tests/lake-lfs-immunity.test.ts`)
157- **framework 值域(方案一:同 sid 双会话)**:proxy 捕获行 = agent 框架 + `-proxy` 后缀(`claude-code-proxy` / `opencode-proxy` / `codex-proxy` / `cannbot-proxy`),与同 sid 原生会话共存两行。**判断一律走谓词,禁止对 framework 裸等值**:格式判断用 `isClaudeFormatSession`(放行 `-proxy` 变体)、agent 归属用 `agentFrameworkOf`(剥后缀)、proxy 与否用 `isProxyVersion(version)`;按 taskId 解析会话的 API 必须接受 `framework` 消歧参数(同 sid 可能两行)157- **framework 值域(方案一:同 sid 双会话)**:proxy 捕获行 = agent 框架 + `-proxy` 后缀(`claude-code-proxy` / `opencode-proxy` / `codex-proxy` / `cannbot-proxy`),与同 sid 原生会话共存两行。**判断一律走谓词,禁止对 framework 裸等值**:格式判断用 `isClaudeFormatSession`(放行 `-proxy` 变体)、agent 归属用 `agentFrameworkOf`(剥后缀)、proxy 与否用 `isProxyVersion(version)`;按 taskId 解析会话的 API 必须接受 `framework` 消歧参数(同 sid 可能两行)
158-- **cannbay 仓布局(同 sid 双记录)**:不变量是「sid × 记录类 全仓唯一」——wire 捕获占裸 `<sid>/`,原生 claude 会话占 `<sid>.native/`(目录内文件用真实 sid 命名)。目录名推导/还原一律走 `sessionFolderOf` / `parseSessionFolder`(mirror.ts),不要手拼 `.native` 后缀;按 sid 定位仓内会话的代码必须带记录类维度(`locateSessionLocations` / `materializeSession` / `uploadFolder` 的 recording 参数,缺省 capture 兼容存量)158+- **lake 仓布局(同 sid 双记录)**:不变量是「sid × 记录类 全仓唯一」——wire 捕获占裸 `<sid>/`,原生 claude 会话占 `<sid>.native/`(目录内文件用真实 sid 命名)。目录名推导/还原一律走 `sessionFolderOf` / `parseSessionFolder`(mirror.ts),不要手拼 `.native` 后缀;按 sid 定位仓内会话的代码必须带记录类维度(`locateSessionLocations` / `materializeSession` / `uploadFolder` 的 recording 参数,缺省 capture 兼容存量)
159-- **双名发布(context 品牌变体)**:`node scripts/pack-context.mjs` 产出 `context-insight` 同内容变体 tgz(staging 精确字面量替换 + 每规则 minHits 哨兵,源码仓零改动)。新增/改动品牌字面量(显示名 `CANNBot-Insight`、CLI 别名 `cbi`、bin 文件名、数据目录 `~/.cannbot-insight`)时必须同步 `scripts/pack-context.mjs` 规则表与 `tests/context-pack.test.ts` 泄漏哨兵;格式契约(`BRAND_SOURCE_TYPE` / `x_cannbay` / `CANNBAY2_*`·`CANNBOT_*` env 名 / `cannbot-smart-agent` 服务标识 / cannbay2 机器提交身份)**不得品牌化**——两品牌数据互通靠它们159+- **双名发布(context 品牌变体)**:`node scripts/pack-context.mjs` 产出 `context-insight` 同内容变体 tgz(staging 精确字面量替换 + 每规则 minHits 哨兵,源码仓零改动)。新增/改动品牌字面量(显示名 `CANNBot-Insight`、CLI 别名 `cbi`、bin 文件名、数据目录 `~/.cannbot-insight`)时必须同步 `scripts/pack-context.mjs` 规则表与 `tests/context-pack.test.ts` 泄漏哨兵;格式契约(`BRAND_SOURCE_TYPE` / `x_cannbay` / `LAKE_*`·`CANNBOT_*` env 名 / `cannbot-smart-agent` 服务标识 / lake 机器提交身份)**不得品牌化**——两品牌数据互通靠它们
160- **ESM strict** (`"type": "module"` in package.json) — no `require()`, use `import`160- **ESM strict** (`"type": "module"` in package.json) — no `require()`, use `import`
161- **Ink v7 has no lastFrame/frames/output** on native `render()` — use `ink-testing-library` for tests only161- **Ink v7 has no lastFrame/frames/output** on native `render()` — use `ink-testing-library` for tests only
162- **No third-party Ink components** — ink-table/ink-select/ink-spinner incompatible with Ink v7; self-implement all components162- **No third-party Ink components** — ink-table/ink-select/ink-spinner incompatible with Ink v7; self-implement all components
@@ -4,6 +4,27 @@
4 4 
5## [Unreleased]5## [Unreleased]
6 6 
7+### Changed
8+- 【数据湖配置单源化:服务端 lake-config.json 唯一配置源】"当前用哪个数据仓"不再依赖浏览器与部署环境——设置页保存的地址/分支/目录落本机服务端 `~/.cannbot-insight/lake-config.json`(与凭据 lake-auth.json 同目录),导入/上传/CANNBay Viewer/CLI 全机共读同一份:换浏览器、Viewer 服务端加载均直接生效(此前 Viewer 只认环境变量,与设置页配置脱节导致加载不出来)。**breaking**:`LAKE_REMOTE_URL` / `LAKE_BRANCH` / `LAKE_PUSH_URL` / `LAKE_USERNAME`+`LAKE_TOKEN` 环境变量通道退役(旧私仓部署形态;CI/脚本改为直接写 lake-config.json + lake-auth.json 两个文件);请求体 config 仅设置页"测试连接/浏览分支/浏览目录"等编辑态探测消费,数据动作(列表/导入/上传)一律服务端解析。同轮修复:viewer 范式优化目标文件(`<范式>/<范式>.jsonl`)不再被误判为根级会话——此前会导致导入范式选择器冒出「根目录」幻影桶、该范式从清单消失(现场 op-fusion 案例);根级会话识别只认上传管道产物形态(.jsonl.7z/.jsonl.gz/.meta.json)
9+ 
10+### Changed
11+- 【数据湖链路平台化 P1:中性命名统一(模块/路由/环境变量/凭据仓/品牌显示名)】数据湖功能与特定数据仓实现解耦,全面中性命名——模块 `src/lib/lake/`(原数据仓模块目录)、配置 `lake-config.ts`、API 路由 `/api/ingest/lake` 与 `/api/settings/lake-auth`、页面 `lake_viewer`(旧路由保留跳转);环境变量前缀统一为 `LAKE_*`(**breaking**:旧前缀不再识别,部署脚本需迁移,对照表见内部文档);凭据仓文件更名为 `lake-auth.json`(旧名文件读兜底——存量凭据无感迁移,读旧写新,首次保存即落新文件);品牌显示名改中性常量(Data Lake / Lake Viewer,双品牌同值,对应打包翻转规则退役、泄漏哨兵针保留防回潮);数据仓地址**无占位默认**:未配置(环境变量空且请求无地址)时 fail-fast 指引配置入口,不再静默指向示例地址(旧版请求体预设形状同步退役)。另退役遗留 v1 硬编码远端导入路由(`import-from-lake` 前身,零调用方;其 symlink 逃逸防护经核验不适用于现代物化路径——blob 内容写普通文件,无链接跟随面)。线上数据格式(`x_cannbay` 信封与 `cc-*` schema 名)保持不变,兼容期后另行双名迁移
12+ 
13+### Changed
14+- 【数据湖链路平台化 P2:双栈统一单一引擎】viewer 侧并行湖实现(镜像/远端/notes 三文件副本)整体并入 `src/lib/lake/` 单一引擎——异步克隆栈(进度上报、`ensureMirrorAsync` 锁外长克隆+短锁交换、`runGitAsync` 进程组击杀)、详细列表(`listSessionsDetailed`:meta 指标快照富形态 + 批量 `cat-file --batch` 预取 + 首传 commit 归属 walk)、notes 记录链(`lib/lake/notes.ts`)全部归位引擎;`runGit` 扩展 env/input 选项(git plumbing 场景);`LAKE_VIEWER_CACHE_DIR` 作为最前优先级兼容链并入统一 cacheDir 决议(双栈时代测试隔离入口零改动);范式模式显式开关以引擎 `paradigmRoots` 单一决议点达成。viewer 目录只留 UI 聚合层(runs/insight-data/format)。镜像缓存统一后 session 大文件只落一处、冷启动异步/同步锁互斥语义保持(回归哨兵锁死)。门禁:ut 1554+2 skipped / e2e 299+6 skipped / tsc 干净 / eslint 零 error
15+ 
16+### Changed
17+- 【数据湖链路平台化 P3:信封双名 + 平台适配收口 + 规范文档更名】捕获/导出 jsonl 扩展字段信封引入新键 `x_lake`——写侧双名过渡(同一信封对象挂 `x_lake` + 旧键两处,老版本读方零损失;cpx 三个 emitter ×14 写点、insight 导出/编排 ×4 写点、评分日志 ×3 写点)、读侧新键优先旧键兜底(线上存量数据零迁移,全链 17 个读点:ingest 双 adapter / 镜像 meta 批读×2 / 编排补列 / 评分装配 / agent 恢复 / wire 轮次 API 等);下个大版本退役旧键。平台差异收口 `lib/lake/platform.ts` 唯一居所(host 归一 / 错误码分诊片段 / 令牌属主反查——引擎其余部分零平台假设,其他托管平台无需适配文件即用通用语义)。格式规范文档更名 `lake-schema-spec.md`(双包全部引用同步)。门禁:insight ut 1554+2 skipped / e2e 299+6 skipped / cpx ut 173 / e2e 10 / 双包 tsc 干净 / eslint 零新增(2 存量基线)
18+ 
19+### Added
20+- 【平台中性哨兵(P1-P3 防回潮锁,词表源 sensitive-info-audit)】`lake-zero-builtin.test.ts` 新增两组 CI 哨兵:①双包 src 词表扫描归零——私仓/组织/用户名/预设名/env 旧前缀/品牌变体任意命中即红;cannbay 裸形态仅白名单三上下文放行(`x_cannbay` 双名信封键、`cannbay-auth.json` 读兜底字面量、schema spec 文档名)——离开双名语境的 cannbay 即回潮;②cpx 信封写点双名锁——每个旧键写点前 25 行窗口内必须有配对 `x_lake` 写点(写侧不双名则老版本读方丢信封)。回潮即红、合入门禁拦截
21+ 
22+### Changed
23+- 【数据仓配置前移到使用现场(Import 点 Data Lake / 会话页上传)——设置页降级为管理主场】新增 \`unconfigured\` 语境的引导式配置弹窗:Import Session 点 Data Lake 源或会话页点上传时,地址未配置(env 与设置页均空)直接弹出**带地址输入框**的连接弹窗(客户端预检零网络往返),填地址+凭据保存后自动重试原操作——首次使用全程不离开导入流、不再被"请前往设置页"的指路打断。凭据缺失/无效语境维持原弹窗(仅凭据输入,地址只读)。设置页保留为管理主场(事后改地址/看已存凭据/分支目录高级项)。存储同源:地址 localStorage lake-config(与设置页同键同值),凭据服务端 lake-auth.json。测试种子策略:`LAKE_REMOTE_URL` env 注入放行预检(localStorage 留空保范式多根模式——自定义地址会关范式模式跳过目录选择步,种子踩坑记录)。门禁:ut 1559+2 skipped / e2e 299+6 skipped
24+ 
25+### Changed
26+- 【品牌显示名复原:CANNbay / CANNBay Viewer】平台化 P1 曾将 BAY_NAME/BAY_VIEWER_NAME 改为中性名(Data Lake/Lake Viewer)——按用户决策复原品牌名:`BAY_NAME = "CANNbay"`(context 变体 `contextBay`)、`BAY_VIEWER_NAME = "CANNBay Viewer"`(context 变体 `contextBay Viewer`)——品牌名是产品显示名不是敏感词(pack-context 翻转为 context 变体,context-pack 哨兵验证零泄漏)。设置页标题复原 `{BAY_NAME} 数据之湖`;pack-context 两条精确字面量翻转规则复原(`"CANNbay"`→`"contextBay"`、`"CANNBay Viewer"`→`"contextBay Viewer"`);export-view 产物重生成。平台中性哨兵同步适配:CANNbay/CANNBay 从全局禁词改为品牌字面量白名单制(branding.ts/pack-context.mjs/context-pack.test.ts 三个合法居所以外出现品牌字面量即回潮——应经 BAY_NAME 常量引用)。门禁:ut 1559+2 skipped / e2e 299+6 skipped / context-pack 品牌哨兵 4/4
27+ 
7### Fixed28### Fixed
8- 【私有数据仓分支下拉 / 测试连接修复】分支列表与「测试连接」改为与服务端已存凭据(`cannbay-auth.json`)同通道鉴权(401 挑战-应答):表单留空也能列出私有仓分支、有效已存凭据不再被误报「连接失败」;gitcode 上无法确认令牌属主时如实提示「凭据未能验证」而非伪称已验证;无凭据指引由已移除的 `cpx auth` 更正为 `cbi auth`29- 【私有数据仓分支下拉 / 测试连接修复】分支列表与「测试连接」改为与服务端已存凭据(`cannbay-auth.json`)同通道鉴权(401 挑战-应答):表单留空也能列出私有仓分支、有效已存凭据不再被误报「连接失败」;gitcode 上无法确认令牌属主时如实提示「凭据未能验证」而非伪称已验证;无凭据指引由已移除的 `cpx auth` 更正为 `cbi auth`
9 30 
@@ -100,11 +100,11 @@ proxy(cpx 捕获)是 insight 的扩展来源,不是核心管线的一部
100 100 
101## Constraints & Gotchas101## Constraints & Gotchas
102 102 
103-- **cannbay 仓布局(同 sid 双记录)**:不变量是「sid × 记录类 全仓唯一」——wire 捕获占裸 `<sid>/`,原生 claude 会话占 `<sid>.native/`(目录内文件用真实 sid 命名)。目录名推导/还原一律走 `sessionFolderOf` / `parseSessionFolder`(mirror.ts),不要手拼 `.native` 后缀;按 sid 定位仓内会话的代码必须带记录类维度(`locateSessionLocations` / `materializeSession` / `uploadFolder` 的 recording 参数,缺省 capture 兼容存量)103+- **lake 仓布局(同 sid 双记录)**:不变量是「sid × 记录类 全仓唯一」——wire 捕获占裸 `<sid>/`,原生 claude 会话占 `<sid>.native/`(目录内文件用真实 sid 命名)。目录名推导/还原一律走 `sessionFolderOf` / `parseSessionFolder`(mirror.ts),不要手拼 `.native` 后缀;按 sid 定位仓内会话的代码必须带记录类维度(`locateSessionLocations` / `materializeSession` / `uploadFolder` 的 recording 参数,缺省 capture 兼容存量)
104 104 
105- **framework 值域(方案一:同 sid 双会话)**:proxy 捕获行 = agent 框架 + `-proxy` 后缀(`claude-code-proxy` / `opencode-proxy` / `codex-proxy` / `cannbot-proxy`),与同 sid 原生会话共存两行。**判断一律走谓词,禁止对 framework 裸等值**:格式判断用 `isClaudeFormatSession`(放行 `-proxy` 变体)、agent 归属用 `agentFrameworkOf`(剥后缀)、proxy 与否用 `isProxyVersion(version)`;按 taskId 解析会话的 API 必须接受 `framework` 消歧参数(同 sid 可能两行)105- **framework 值域(方案一:同 sid 双会话)**:proxy 捕获行 = agent 框架 + `-proxy` 后缀(`claude-code-proxy` / `opencode-proxy` / `codex-proxy` / `cannbot-proxy`),与同 sid 原生会话共存两行。**判断一律走谓词,禁止对 framework 裸等值**:格式判断用 `isClaudeFormatSession`(放行 `-proxy` 变体)、agent 归属用 `agentFrameworkOf`(剥后缀)、proxy 与否用 `isProxyVersion(version)`;按 taskId 解析会话的 API 必须接受 `framework` 消歧参数(同 sid 可能两行)
106 106 
107-- **双名发布(context 品牌变体)**:`node scripts/pack-context.mjs` 产出 `context-insight` 同内容变体 tgz(staging 精确字面量替换 + 每规则 minHits 哨兵,源码仓零改动)。新增/改动品牌字面量(显示名 `CANNBot-Insight`、CLI 别名 `cbi`、bin 文件名、数据目录 `~/.cannbot-insight`)时必须同步 `scripts/pack-context.mjs` 规则表与 `tests/context-pack.test.ts` 泄漏哨兵;格式契约(`BRAND_SOURCE_TYPE` / `x_cannbay` / `CANNBAY2_*`·`CANNBOT_*` env 名 / `cannbot-smart-agent` 服务标识 / cannbay2 机器提交身份)**不得品牌化**——两品牌数据互通靠它们107+- **双名发布(context 品牌变体)**:`node scripts/pack-context.mjs` 产出 `context-insight` 同内容变体 tgz(staging 精确字面量替换 + 每规则 minHits 哨兵,源码仓零改动)。新增/改动品牌字面量(显示名 `CANNBot-Insight`、CLI 别名 `cbi`、bin 文件名、数据目录 `~/.cannbot-insight`)时必须同步 `scripts/pack-context.mjs` 规则表与 `tests/context-pack.test.ts` 泄漏哨兵;格式契约(`BRAND_SOURCE_TYPE` / `x_cannbay` / `LAKE_*`·`CANNBOT_*` env 名 / `cannbot-smart-agent` 服务标识 / lake 机器提交身份)**不得品牌化**——两品牌数据互通靠它们
108- **ESM strict** (`"type": "module"` in package.json) — no `require()`, use `import`108- **ESM strict** (`"type": "module"` in package.json) — no `require()`, use `import`
109- **Ink v7 has no lastFrame/frames/output** on native `render()` — use `ink-testing-library` for tests only109- **Ink v7 has no lastFrame/frames/output** on native `render()` — use `ink-testing-library` for tests only
110- **No third-party Ink components** — ink-table/ink-select/ink-spinner incompatible with Ink v7; self-implement all components110- **No third-party Ink components** — ink-table/ink-select/ink-spinner incompatible with Ink v7; self-implement all components
@@ -85,7 +85,7 @@
85│ AI 分析: analyzer.ts (OpenAI 兼容) │ trajectory-analyzer.ts │85│ AI 分析: analyzer.ts (OpenAI 兼容) │ trajectory-analyzer.ts │
86│ 上下文: context-window-config.ts │ Web ContextTracker.tsx (需手动同步) │86│ 上下文: context-window-config.ts │ Web ContextTracker.tsx (需手动同步) │
87│ 导出: export-service.ts │ markdown-exporter.ts │ agent-io-export.ts │87│ 导出: export-service.ts │ markdown-exporter.ts │ agent-io-export.ts │
88-│ 归档: cannbay-archive.ts │ zip-store.ts │ file-restore / file-reads.ts │88+│ 归档: lake-archive.ts │ zip-store.ts │ file-restore / file-reads.ts │
89│ 版本: version.ts (展示版本,非 package.json) │89│ 版本: version.ts (展示版本,非 package.json) │
90└─────────────────────────────────────────────────────────────────────────────┘90└─────────────────────────────────────────────────────────────────────────────┘
91```91```
@@ -171,7 +171,7 @@ flowchart TB
171 AI["AI analyzer.ts<br/>(OpenAI 兼容)"]171 AI["AI analyzer.ts<br/>(OpenAI 兼容)"]
172 CTX["context-window-config"]172 CTX["context-window-config"]
173 EXP["export / markdown / agent-io"]173 EXP["export / markdown / agent-io"]
174- ARC["cannbay-archive / zip-store"]174+ ARC["lake-archive / zip-store"]
175 VER["version.ts"]175 VER["version.ts"]
176 end176 end
177 177 
Rpackages/insight/docs/cannbay-schema-spec.md→packages/insight/docs/lake-schema-spec.md+9-6
@@ -1,6 +1,6 @@
1# CANNBay 捕获/导出格式标准(x_cannbay v1)1# CANNBay 捕获/导出格式标准(x_cannbay v1)
2 2 
3-适用范围:cpx 捕获文件、insight DB 导出件、CANNBay 仓内 `sessions/<sid>/` 布局的全部 jsonl/meta 数据。3+适用范围:cpx 捕获文件、insight DB 导出件、数据湖仓内 `<目录>/<sid>/` 布局的全部 jsonl/meta 数据。
4 4 
5## 0. 什么场景要看本文档5## 0. 什么场景要看本文档
6 6 
@@ -8,7 +8,7 @@
8 8 
9| 场景 | 时刻 | 义务 |9| 场景 | 时刻 | 义务 |
10|---|---|---|10|---|---|---|
11-| **生产** | cpx 捕获落盘(每 wire 轮);cpx normalize;CANNBay 上传(直拷 / DB 导出);任何写 `sessions/<sid>/` 布局的新工具 | 写方义务:信封不碰、扩展进口袋、带声明 |11+| **生产** | cpx 捕获落盘(每 wire 轮);cpx normalize;CANNBay 上传(直拷 / DB 导出);任何写 `<目录>/<sid>/` 布局的新工具 | 写方义务:信封不碰、扩展进口袋、带声明 |
12| **消费** | cpx 退出自动导入 / 手动导入 / CANNBay 导入;turns API 读 Full Context 与 wire enrichment;任何解析这些文件的新代码 | 读方义务:按 `(schema, version)` 分发,不认识整块跳过,禁止字段嗅探 |12| **消费** | cpx 退出自动导入 / 手动导入 / CANNBay 导入;turns API 读 Full Context 与 wire enrichment;任何解析这些文件的新代码 | 读方义务:按 `(schema, version)` 分发,不认识整块跳过,禁止字段嗅探 |
13| **演进** | 加扩展字段、改字段语义、加新 payload schema | 见 §1 修改规则 |13| **演进** | 加扩展字段、改字段语义、加新 payload schema | 见 §1 修改规则 |
14 14 
@@ -23,14 +23,14 @@
23| **加新 payload schema** | ✅ | 补齐本文档字段表、类型定义、形状校验、混排测试四项后合入 |23| **加新 payload schema** | ✅ | 补齐本文档字段表、类型定义、形状校验、混排测试四项后合入 |
24| **改信封层字段的含义**(`type` / `timestamp` / `message{role,content,id,model,usage}`) | ❌ 永久禁止 | 信封是 claude 原生语义,冻结是本标准的存在前提 |24| **改信封层字段的含义**(`type` / `timestamp` / `message{role,content,id,model,usage}`) | ❌ 永久禁止 | 信封是 claude 原生语义,冻结是本标准的存在前提 |
25| **复用信封字段装别的数据**(如 `duration_ms` 装 wire latency) | ❌(存量例外见 §6 双轨说明) | 一律进 `x_cannbay.data` |25| **复用信封字段装别的数据**(如 `duration_ms` 装 wire latency) | ❌(存量例外见 §6 双轨说明) | 一律进 `x_cannbay.data` |
26-| **改物理布局**(`sessions/<sid>/` 文件树) | ⚠️ 谨慎 | 布局是 adapter 发现约定(子代理目录、meta 命名),改动需同步 adapter 发现逻辑 |26+| **改物理布局**(`<目录>/<sid>/` 文件树) | ⚠️ 谨慎 | 布局是 adapter 发现约定(子代理目录、meta 命名),改动需同步 adapter 发现逻辑 |
27 27 
28加字段不升版本、改语义不升版本均视为破坏契约。每次 payload 改动须同步更新形状校验测试。28加字段不升版本、改语义不升版本均视为破坏契约。每次 payload 改动须同步更新形状校验测试。
29 29 
30## 2. 物理布局30## 2. 物理布局
31 31 
32```32```
33-<paradigm>/<sid>/ # 范式目录(顶层目录即范式注册表,默认仓上传必选)33+<paradigm>/<sid>/ # 范式目录(顶层目录即范式注册表,上传必选范式时落此)
34 <sid>.jsonl.7z # 主会话行流(信封 + x_cannbay;7z at rest,存量 .jsonl.gz 与旧件明文 .jsonl 永续可读)34 <sid>.jsonl.7z # 主会话行流(信封 + x_cannbay;7z at rest,存量 .jsonl.gz 与旧件明文 .jsonl 永续可读)
35 <sid>.meta.json # 主会话元数据(cc-session-meta,永远明文)35 <sid>.meta.json # 主会话元数据(cc-session-meta,永远明文)
36 subagents/36 subagents/
@@ -38,7 +38,9 @@
38 <subId>.meta.json # 子代理关联元数据(cc-subagent-meta,明文)38 <subId>.meta.json # 子代理关联元数据(cc-subagent-meta,明文)
39<paradigm>/baseline/<sid>/ # 范式基准会话(workflow 评分基准;位置即身份:39<paradigm>/baseline/<sid>/ # 范式基准会话(workflow 评分基准;位置即身份:
40 … # baseline/ 下唯一 <sid>/ 即该范式当前基准,无标记文件)40 … # baseline/ 下唯一 <sid>/ 即该范式当前基准,无标记文件)
41-sessions/<sid>/ # 存量区(范式目录上线前的布局,只读保留,导入照常)41+<sid>/ # 根级(上传缺省位:未指定范式/directory 时的落盘处;
42+ … # 列表归「根目录」桶。'sessions' 存量区默认已退役——
43+ # sessions/ 若存在即普通范式目录,同布局同待遇)
42```44```
43 45 
44范式目录 = 数据仓顶层目录(`elementwise` / `contraction` / `others` …,25 个46范式目录 = 数据仓顶层目录(`elementwise` / `contraction` / `others` …,25 个
@@ -48,7 +50,8 @@ sessions/<sid>/ # 存量区(范式目录上线前的布局,只
48重传 = 同 commit 删旧目录写新目录。`baseline/` 为范式级保留目录名:设为50重传 = 同 commit 删旧目录写新目录。`baseline/` 为范式级保留目录名:设为
49基准的会话住进 `<paradigm>/baseline/<sid>/`,更换基准 = 一次 commit 原子51基准的会话住进 `<paradigm>/baseline/<sid>/`,更换基准 = 一次 commit 原子
50交换(新会话进 baseline、原基准移回范式普通目录)——结构即状态,无声明52交换(新会话进 baseline、原基准移回范式普通目录)——结构即状态,无声明
51-文件。自定义数据湖远端不启用范式目录(维持 `directory` 单根机制)。53+文件。自定义 `directory` 仅作上传缺省目录(留空 = 仓库根根级位),列表/
54+导入恒多根全仓扫描。
52 55 
53压缩(7z at rest,见 `proxy/COMPRESSION-DESIGN.md`;存量 gz 同理)是**传输编码**:解压后56压缩(7z at rest,见 `proxy/COMPRESSION-DESIGN.md`;存量 gz 同理)是**传输编码**:解压后
54行流与明文形态逐字节一致,信封与 `x_cannbay` 契约不变;治理(密钥清洗/复检)57行流与明文形态逐字节一致,信封与 `x_cannbay` 契约不变;治理(密钥清洗/复检)
@@ -16,11 +16,11 @@
16//16//
17// 品牌边界(与 cannbot-insight 同内容、仅品牌面不同):17// 品牌边界(与 cannbot-insight 同内容、仅品牌面不同):
18// 翻转 —— 显示名 / CLI 别名 / bin 文件名 / 数据目录(~/.context-insight)/18// 翻转 —— 显示名 / CLI 别名 / bin 文件名 / 数据目录(~/.context-insight)/
19-// Bay 显示名(UI 字符串一律经 BAY_NAME 常量(@/lib/branding)取值,19+// Bay 显示名(UI 字符串一律经 BAY_NAME/BAY_VIEWER_NAME 常量
20-// 品牌随 BRAND_SLUG 翻转;branding.ts 里的 "CANNbay" 字面量由下方20+// (@/lib/branding)取值,品牌随 BRAND_SLUG 翻转;branding.ts 里的
21-// 通用规则恒等化,仅为让哨兵测试零误报)21+// "CANNbay" 字面量由下方通用规则恒等化,仅为让哨兵测试零误报)
22// 保留 —— 数据格式标识(BRAND_SOURCE_TYPE="cannbot-insight",两品牌数据互通)/22// 保留 —— 数据格式标识(BRAND_SOURCE_TYPE="cannbot-insight",两品牌数据互通)/
23-// x_cannbay 命名空间 / CANNBAY2_* 与 CANNBOT_* 环境变量名 /23+// x_cannbay 命名空间 / LAKE_* 与 CANNBOT_* 环境变量名 /
24// smart-agent 服务标识(cannbot-smart-agent,启动器 isSmartAgent 契约)/24// smart-agent 服务标识(cannbot-smart-agent,启动器 isSmartAgent 契约)/
25// git 机器提交身份(cannbay2 mirror,数据仓内身份稳定)/25// git 机器提交身份(cannbay2 mirror,数据仓内身份稳定)/
26// v1 路由仓 URL(真实仓库名,非品牌显示,26// v1 路由仓 URL(真实仓库名,非品牌显示,
@@ -120,7 +120,7 @@ export async function POST(req: Request) {
120 120 
121 // 喂法按 sourcePath 形态分流(framework 与捕获格式正交,见 session-format.ts):121 // 喂法按 sourcePath 形态分流(framework 与捕获格式正交,见 session-format.ts):
122 // .db → --db(直读 opencode 原生 sessions.db);122 // .db → --db(直读 opencode 原生 sessions.db);
123- // .jsonl / .jsonl.7z / .jsonl.gz(proxy 捕获、cannbay 导入的 insight-export 再导出)→123+ // .jsonl / .jsonl.7z / .jsonl.gz(proxy 捕获、lake 导入的 insight-export 再导出)→
124 // --transcript(结构化 records JSON)——framework=opencode 但 sourcePath 是124 // --transcript(结构化 records JSON)——framework=opencode 但 sourcePath 是
125 // jsonl 的会话走 --db 会被 sift 当 sqlite 打开直接 OpencodeDbError。125 // jsonl 的会话走 --db 会被 sift 当 sqlite 打开直接 OpencodeDbError。
126 const useTranscript = !/\.db$/i.test(session.sourcePath ?? "")126 const useTranscript = !/\.db$/i.test(session.sourcePath ?? "")
@@ -1,244 +0,0 @@
1-// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd.
2-// This program is free software, you can redistribute it and/or modify it under the terms and conditions of
3-// CANN Open Software License Agreement Version 2.0 (the "License").
4-// Please refer to the License for details. You may not use this file except in compliance with the License.
5-// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,
6-// INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
7-// See LICENSE in the root of the software repository for the full text of the License.
8- 
9-import { NextRequest, NextResponse } from 'next/server';
10-import { importSession } from '@/lib/ingest/data-service';
11-import { BRAND_SOURCE_TYPE, BAY_NAME } from '@/lib/branding';
12-import { prisma } from '@/lib/db';
13-import fs from 'node:fs';
14-import path from 'node:path';
15-import os from 'node:os';
16-import Database from 'better-sqlite3';
17-import { execFileSync } from 'node:child_process';
18-import { isPathInside } from '@/lib/ingest/import-paths';
19- 
20-const CANNBAY_PULL_URL = 'https://example.com/CANNBay.git';
21- 
22-function runGit(args: string[], cwd: string) {
23- return execFileSync('git', args, { cwd, stdio: 'pipe', timeout: 120_000 });
24-}
25- 
26-// isPathInside 是纯词法比较(path.resolve 不解析 symlink):镜像 worktree 会原样
27-// 物化仓里提交的链接,词法校验 + existsSync 都拦不住指向克隆目录外的 symlink,
28-// 随后 Database/importSession 会顺着链接读到本地文件。落地 sink 前按真实路径再校验。
29-function realPathInside(root: string, p: string): boolean {
30- try {
31- return isPathInside(fs.realpathSync(root), fs.realpathSync(p));
32- } catch {
33- return false;
34- }
35-}
36- 
37-// 解析 cannbay 提交约定:`提交人:<姓名> 内容描述:<描述>`(冒号兼容半角/全角,
38-// 描述词兼容"内容描述"/"描述",提交人可为空)。
39-// 失败时 submitter 回退 git author,description 回退完整 subject。
40-function parseCommitSubject(subject: string, author: string): { submitter: string; description: string } {
41- let submitter = '';
42- let description = '';
43- if (subject) {
44- const subMatch = subject.match(/提交人\s*[::]\s*/);
45- const descMatch = subject.match(/(?:内容)?描述\s*[::]\s*/);
46- const subStart = subMatch ? (subMatch.index ?? 0) + subMatch[0].length : -1;
47- const descIdx = descMatch ? (descMatch.index ?? 0) : -1;
48- if (subStart >= 0) {
49- // 提交人取到 描述标记 之前;描述标记不存在则取到行尾
50- // (>= 而非 >:当提交人为空、描述标记紧随其后时,didx==subStart,应切出空名→回退 author)
51- const end = descIdx >= subStart ? descIdx : subject.length;
52- submitter = subject.slice(subStart, end).trim();
53- }
54- if (descIdx >= 0) {
55- description = subject.slice(descIdx + (descMatch![0].length)).trim();
56- }
57- }
58- if (!submitter) submitter = author;
59- if (!description) description = subject;
60- return { submitter, description };
61-}
62- 
63-export const maxDuration = 300;
64- 
65-// 持久化本地镜像:首次全量 clone,之后每次只 fetch+reset 增量(.db 加一次不改,增量极小)。
66-// 避免每次 list/import 都全量下载 ~300MB。
67-const CANNBAY_CACHE_DIR = process.env.CANNBAY_CACHE_DIR
68- || path.join(process.cwd(), 'tmp', 'cannbay-cache');
69- 
70-let cloneLock: Promise<string> | null = null;
71- 
72-function ensureCannbayClone(): Promise<string> {
73- if (cloneLock) return cloneLock;
74- cloneLock = (async () => {
75- const cacheDir = CANNBAY_CACHE_DIR;
76- fs.mkdirSync(path.dirname(cacheDir), { recursive: true });
77- if (fs.existsSync(path.join(cacheDir, '.git'))) {
78- try {
79- // 增量更新:只拉取 master 新 commit,硬重置工作树
80- runGit(['fetch', '--quiet', 'origin', 'master'], cacheDir);
81- runGit(['reset', '--hard', '--quiet', 'origin/master'], cacheDir);
82- runGit(['clean', '-fdq'], cacheDir);
83- return cacheDir;
84- } catch {
85- // 镜像损坏或历史被强推 → 删除重建
86- fs.rmSync(cacheDir, { recursive: true, force: true });
87- }
88- }
89- fs.rmSync(cacheDir, { recursive: true, force: true });
90- runGit(['clone', '--quiet', CANNBAY_PULL_URL, cacheDir], os.tmpdir());
91- return cacheDir;
92- })().finally(() => { cloneLock = null; });
93- return cloneLock;
94-}
95- 
96-export async function POST(request: NextRequest) {
97- try {
98- const body = await request.json();
99- const { action } = body;
100- 
101- // 持久化镜像(增量更新),不再每次全量 clone 到临时目录
102- let cloneDir: string;
103- try {
104- cloneDir = await ensureCannbayClone();
105- } catch (cloneErr) {
106- const msg = cloneErr instanceof Error ? cloneErr.message : 'Clone failed';
107- return NextResponse.json({ error: `Failed to clone ${BAY_NAME}: ${msg}` }, { status: 500 });
108- }
109- 
110- try {
111- if (action === 'list') {
112- // 扫描根目录 .db 文件 + 一层子目录里的 .db 文件(兼容 session 文件夹上传)
113- const files: string[] = [];
114- const rootEntries = fs.readdirSync(cloneDir, { withFileTypes: true });
115- for (const entry of rootEntries) {
116- if (entry.isDirectory() && entry.name !== '.git') {
117- try {
118- const subEntries = fs.readdirSync(path.join(cloneDir, entry.name), { withFileTypes: true });
119- for (const sub of subEntries) {
120- if (sub.isFile() && sub.name.endsWith('.db')) {
121- files.push(path.join(entry.name, sub.name));
122- }
123- }
124- } catch {}
125- } else if (entry.isFile() && entry.name.endsWith('.db')) {
126- files.push(entry.name);
127- }
128- }
129- 
130- const sessions: Array<{
131- filename: string;
132- taskId: string;
133- query: string | null;
134- author: string;
135- submitter: string;
136- commitTime: string;
137- commitMessage: string;
138- size: number;
139- }> = [];
140- 
141- for (const f of files) {
142- const fullPath = path.join(cloneDir, f);
143- if (!realPathInside(cloneDir, fullPath)) continue;
144- const stat = fs.statSync(fullPath);
145- 
146- const taskIdMatch = path.basename(f).match(/^cannbot_db_session_(.+?)_/);
147- const taskId = taskIdMatch ? taskIdMatch[1] : path.basename(f).replace(/\.db$/, '');
148- 
149- // 每个文件的最后改动 commit:作者 / 时间 / subject(需要完整历史,故非 --depth 1)
150- let author = '';
151- let commitTime = '';
152- let commitSubject = '';
153- try {
154- const out = runGit(
155- ['log', '-1', '--pretty=format:%an%x1f%ad%x1f%s', '--date=iso-strict', '--', f],
156- cloneDir,
157- ).toString().trim();
158- const parts = out.split('\x1f');
159- author = parts[0] ?? '';
160- commitTime = parts[1] ?? '';
161- commitSubject = parts.slice(2).join('\x1f');
162- } catch {}
163- 
164- const { submitter, description } = parseCommitSubject(commitSubject, author);
165- 
166- let query: string | null = null;
167- try {
168- const db = new Database(fullPath, { readonly: true });
169- const row = db.prepare(
170- 'SELECT query FROM "Session" LIMIT 1'
171- ).get() as { query: string | null } | undefined;
172- if (row) query = row.query;
173- db.close();
174- } catch {}
175- 
176- sessions.push({ filename: f, taskId, query, author, submitter, commitTime, commitMessage: description, size: stat.size });
177- }
178- 
179- // 按提交时间降序(最新上传在最前)
180- sessions.sort((a, b) => (b.commitTime || '').localeCompare(a.commitTime || ''));
181- 
182- return NextResponse.json({ sessions });
183- }
184- 
185- if (action === 'import') {
186- const { filenames } = body as { filenames: string[] };
187- if (!filenames || filenames.length === 0) {
188- return NextResponse.json({ error: 'Missing filenames' }, { status: 400 });
189- }
190- 
191- const results: Array<{
192- filename: string;
193- taskId: string;
194- imported: boolean;
195- query: string | null;
196- error?: string;
197- }> = [];
198- 
199- for (const f of filenames) {
200- if (typeof f !== 'string' || !isPathInside(cloneDir, f)) {
201- results.push({ filename: f, taskId: '', imported: false, query: null, error: `Invalid filename (must stay inside ${BAY_NAME} clone)` });
202- continue;
203- }
204- const fullPath = path.join(cloneDir, f);
205- if (!fs.existsSync(fullPath)) {
206- results.push({ filename: f, taskId: '', imported: false, query: null, error: `File not found in ${BAY_NAME}` });
207- continue;
208- }
209- if (!realPathInside(cloneDir, fullPath)) {
210- results.push({ filename: f, taskId: '', imported: false, query: null, error: `Invalid filename (must stay inside ${BAY_NAME} clone)` });
211- continue;
212- }
213- 
214- let sessionId = '';
215- try {
216- const db = new Database(fullPath, { readonly: true });
217- const row = db.prepare('SELECT taskId FROM "Session" LIMIT 1').get() as { taskId: string } | undefined;
218- sessionId = row?.taskId ?? path.basename(f).replace(/^cannbot_db_session_/, '').replace(/_\w+\.db$/, '');
219- db.close();
220- } catch {
221- sessionId = path.basename(f).replace(/^cannbot_db_session_/, '').replace(/_\w+\.db$/, '');
222- }
223- 
224- try {
225- const result = await importSession(fullPath, sessionId, prisma, fullPath, BRAND_SOURCE_TYPE);
226- results.push({ filename: f, taskId: result.sessionId, imported: result.imported, query: result.query ?? null });
227- } catch (err) {
228- results.push({ filename: f, taskId: sessionId, imported: false, query: null, error: err instanceof Error ? err.message : 'Import failed' });
229- }
230- }
231- 
232- return NextResponse.json({ results });
233- }
234- 
235- return NextResponse.json({ error: `Unknown action: "${action}". Supported: list, import` }, { status: 400 });
236- } catch (err) {
237- const message = err instanceof Error ? err.message : 'Unknown error';
238- return NextResponse.json({ error: message }, { status: 500 });
239- }
240- } catch (err) {
241- const message = err instanceof Error ? err.message : 'Unknown error';
242- return NextResponse.json({ error: message }, { status: 500 });
243- }
244-}
Rpackages/insight/src/app/api/ingest/cannbay2/route.ts→packages/insight/src/app/api/ingest/lake/route.ts+39-32
@@ -7,47 +7,51 @@
7// See LICENSE in the root of the software repository for the full text of the License.7// See LICENSE in the root of the software repository for the full text of the License.
8 8 
9import { NextRequest, NextResponse } from 'next/server';9import { NextRequest, NextResponse } from 'next/server';
10-import { listCannbay2Overview, importCannbay2Session, uploadCannbay2Session, GovernanceError } from '@/lib/cannbay2';10+import { listLakeOverview, importLakeSession, uploadLakeSession, GovernanceError } from '@/lib/lake';
11-import { resolveCannbay2Remote, testCannbay2Remote, listRemoteBranches, redactRemoteSecrets, normalizeDirPrefix } from '@/lib/cannbay2/remote';11+import { resolveLakeRemote, resolveAdhocRemote, testLakeRemote, listRemoteBranches, redactRemoteSecrets, normalizeDirPrefix } from '@/lib/lake/remote';
12-import { classifyCannbayError } from '@/lib/cannbay2/auth';12+import { classifyLakeError } from '@/lib/lake/auth';
13-import { listRemoteDirs } from '@/lib/cannbay2/mirror';13+import { listRemoteDirs } from '@/lib/lake/mirror';
14-import type { CannbayRemoteInput } from '@/lib/cannbay-config';14+import type { LakeRemoteInput } from '@/lib/lake-config';
15import { prisma } from '@/lib/db';15import { prisma } from '@/lib/db';
16 16 
17export const maxDuration = 300;17export const maxDuration = 300;
18 18 
19export async function POST(request: NextRequest) {19export async function POST(request: NextRequest) {
20- let remote = resolveCannbay2Remote();20+ // 单源架构:数据动作(list/dirs/import/upload)的远端一律 resolveLakeRemote()
21+ // 读服务端 lake-config.json,请求体 config 对它们无意义(忽略)。探测动作
22+ // (test/branches/dirs 浏览)走 resolveAdhocRemote 拿编辑态显式值。remote
23+ // 在各分支内按需 resolve;fail() 脱敏消费 nullable remote
24+ let remote: ReturnType<typeof resolveLakeRemote> | null = null;
21 let secrets: (string | undefined)[] = [];25 let secrets: (string | undefined)[] = [];
22- // 错误出边界统一走这里:消息脱敏 + classifyCannbayError 产出结构化 code26+ // 错误出边界统一走这里:消息脱敏 + classifyLakeError 产出结构化 code
23 // (no-credentials/auth-failed/forbidden/quota/unknown)——前端据 code 给27 // (no-credentials/auth-failed/forbidden/quota/unknown)——前端据 code 给
24 // 「前往设置页配置凭据」等定向引导,而不是让用户看脱敏后的 git 原文28 // 「前往设置页配置凭据」等定向引导,而不是让用户看脱敏后的 git 原文
25 const fail = (err: unknown, status = 500) => {29 const fail = (err: unknown, status = 500) => {
26- const code = classifyCannbayError(err);30+ const code = classifyLakeError(err);
27 const message = redactRemoteSecrets(31 const message = redactRemoteSecrets(
28- err instanceof Error ? err.message : String(err), remote, secrets,32+ err instanceof Error ? err.message : String(err), remote ?? undefined, secrets,
29 );33 );
30 return NextResponse.json({ error: message, code }, { status: code === 'no-credentials' ? 400 : status });34 return NextResponse.json({ error: message, code }, { status: code === 'no-credentials' ? 400 : status });
31 };35 };
32 try {36 try {
33 const body = await request.json();37 const body = await request.json();
34- const { action, config } = body as { action?: string; config?: CannbayRemoteInput };38+ const { action, config } = body as { action?: string; config?: LakeRemoteInput };
35 secrets = [config?.account, config?.password, config?.token];39 secrets = [config?.account, config?.password, config?.token];
36 40 
37- // test/branches 内部自行 resolve,但校验异常须与 list/dirs 一致按 400 出。41+ // test/branches:编辑态探测(adhoc)——校验异常按 400 出。branches 是
38- // branches 是分支选择器:容忍用户正在编辑的脏 branch 字段(不消费它)42+ // 分支选择器:容忍用户正在编辑的脏 branch 字段(不消费它)
39 if (config && (action === 'test' || action === 'branches')) {43 if (config && (action === 'test' || action === 'branches')) {
40 const toValidate = action === 'branches' ? { ...config, branch: undefined } : config;44 const toValidate = action === 'branches' ? { ...config, branch: undefined } : config;
41 try {45 try {
42- resolveCannbay2Remote(toValidate);46+ resolveAdhocRemote(toValidate);
43 } catch (err) {47 } catch (err) {
44- const message = err instanceof Error ? err.message : 'Invalid cannbay config';48+ const message = err instanceof Error ? err.message : 'Invalid lake config';
45 return NextResponse.json({ error: message }, { status: 400 });49 return NextResponse.json({ error: message }, { status: 400 });
46 }50 }
47 }51 }
48 52 
49 if (action === 'test') {53 if (action === 'test') {
50- return NextResponse.json(await testCannbay2Remote(config));54+ return NextResponse.json(await testLakeRemote(config));
51 }55 }
52 56 
53 if (action === 'branches') {57 if (action === 'branches') {
@@ -59,24 +63,12 @@ export async function POST(request: NextRequest) {
59 }63 }
60 }64 }
61 65 
62- try {
63- remote = resolveCannbay2Remote(config);
64- } catch (err) {
65- const message = err instanceof Error ? err.message : 'Invalid cannbay config';
66- return NextResponse.json({ error: message }, { status: 400 });
67- }
68- 
69- if (action === 'list') {
70- // sessions + 顶层目录(dirs)一次返回:导入对话框的范式下拉 + 会话表
71- // 共享单轮 mirror fetch(原先 dirs/list 两路各 fetch 一次)。dirs 字段
72- // 与 action=dirs 同源(含 sessions/,调用方自行过滤);自定义单根远端为 []
73- const { sessions, topDirs } = listCannbay2Overview(remote);
74- return NextResponse.json({ sessions, dirs: topDirs });
75- }
76- 
77 if (action === 'dirs') {66 if (action === 'dirs') {
67+ // 目录浏览(设置页选择上传目录):编辑态地址(adhoc)——浏览的就是
68+ // 用户正在输入/已保存表单里的远端
78 const { prefix } = body as { prefix?: string };69 const { prefix } = body as { prefix?: string };
79 try {70 try {
71+ remote = resolveAdhocRemote(config);
80 const dirs = listRemoteDirs(remote, normalizeDirPrefix(prefix));72 const dirs = listRemoteDirs(remote, normalizeDirPrefix(prefix));
81 return NextResponse.json({ dirs });73 return NextResponse.json({ dirs });
82 } catch (err) {74 } catch (err) {
@@ -85,6 +77,21 @@ export async function POST(request: NextRequest) {
85 }77 }
86 }78 }
87 79 
80+ // 数据动作:单源(服务端 lake-config.json),忽略请求体 config
81+ try {
82+ remote = resolveLakeRemote();
83+ } catch (err) {
84+ const message = err instanceof Error ? err.message : 'Invalid lake config';
85+ return NextResponse.json({ error: message }, { status: 400 });
86+ }
87+ 
88+ if (action === 'list') {
89+ // sessions + 顶层目录(dirs)一次返回:导入对话框的范式下拉 + 会话表
90+ // 共享单轮 mirror fetch(原先 dirs/list 两路各 fetch 一次 ~1s×2)
91+ const { sessions, topDirs } = listLakeOverview(remote);
92+ return NextResponse.json({ sessions, dirs: topDirs });
93+ }
94+ 
88 if (action === 'import') {95 if (action === 'import') {
89 const { sid, recording } = body as { sid: string; recording?: string };96 const { sid, recording } = body as { sid: string; recording?: string };
90 if (!sid) {97 if (!sid) {
@@ -96,7 +103,7 @@ export async function POST(request: NextRequest) {
96 return NextResponse.json({ error: `Invalid recording: "${recording}" (capture | native)` }, { status: 400 });103 return NextResponse.json({ error: `Invalid recording: "${recording}" (capture | native)` }, { status: 400 });
97 }104 }
98 try {105 try {
99- const result = await importCannbay2Session(sid, prisma, remote, recording === 'native' ? 'native' : 'capture');106+ const result = await importLakeSession(sid, prisma, remote, recording === 'native' ? 'native' : 'capture');
100 return NextResponse.json({ result });107 return NextResponse.json({ result });
101 } catch (err) {108 } catch (err) {
102 return fail(err);109 return fail(err);
@@ -112,7 +119,7 @@ export async function POST(request: NextRequest) {
112 const opGenValue = opGen === 'success' || opGen === 'failure' ? opGen : undefined;119 const opGenValue = opGen === 'success' || opGen === 'failure' ? opGen : undefined;
113 try {120 try {
114 // framework:同 sid 双行(方案一)消歧——从捕获行页面上传必须带121 // framework:同 sid 双行(方案一)消歧——从捕获行页面上传必须带
115- const result = await uploadCannbay2Session(prisma, taskId, description, remote, typeof category === 'string' ? category : undefined, baseline === true, opGenValue, typeof framework === 'string' ? framework : undefined);122+ const result = await uploadLakeSession(prisma, taskId, description, remote, typeof category === 'string' ? category : undefined, baseline === true, opGenValue, typeof framework === 'string' ? framework : undefined);
116 return NextResponse.json(result);123 return NextResponse.json(result);
117 } catch (err) {124 } catch (err) {
118 if (err instanceof GovernanceError) {125 if (err instanceof GovernanceError) {
@@ -55,7 +55,7 @@ export async function GET(request: NextRequest) {
55 select: { id: true, taskId: true, sourcePath: true },55 select: { id: true, taskId: true, sourcePath: true },
56 });56 });
57 57 
58- // ① system 注入恢复(sourcePath 指向本地 jsonl——本机 cpx 捕获与 cannbay58+ // ① system 注入恢复(sourcePath 指向本地 jsonl——本机 cpx 捕获与 lake
59 // materialized 布局一致(<sid>/subagents/),listSubagentSessions 统一发现)59 // materialized 布局一致(<sid>/subagents/),listSubagentSessions 统一发现)
60 const recoverFromSystem = () => {60 const recoverFromSystem = () => {
61 if (!session?.sourcePath) return null;61 if (!session?.sourcePath) return null;
@@ -10,6 +10,7 @@ import { NextRequest, NextResponse } from 'next/server';
10import { prisma } from '@/lib/db';10import { prisma } from '@/lib/db';
11import { isClaudeFormatSession } from '@/lib/shared/session-format';11import { isClaudeFormatSession } from '@/lib/shared/session-format';
12import { parseJsonlLines } from '@/lib/ingest/adapters/claude-jsonl';12import { parseJsonlLines } from '@/lib/ingest/adapters/claude-jsonl';
13+import { readEnvelope } from '@/lib/shared/envelope';
13 14 
14// Wire-rounds view: rebuild each WIRE REQUEST directly from the capture15// Wire-rounds view: rebuild each WIRE REQUEST directly from the capture
15// jsonl, independent of the DB/import pipeline. The file stores each message16// jsonl, independent of the DB/import pipeline. The file stores each message
@@ -110,7 +111,7 @@ export async function GET(request: NextRequest) {
110 for (const line of lines) {111 for (const line of lines) {
111 if (line.type === 'assistant' && line.message) {112 if (line.type === 'assistant' && line.message) {
112 if (!pending) {113 if (!pending) {
113- const xb = (line as { x_cannbay?: { schema?: string; version?: number; data?: { roundIndex?: number } } }).x_cannbay;114+ const xb = readEnvelope(line);
114 pending = { msgsSnapshot: snapshot(), blocks: [], model: line.message.model ?? null, usage: line.message.usage ?? null, ts: line.timestamp ?? null, text: '', xb };115 pending = { msgsSnapshot: snapshot(), blocks: [], model: line.message.model ?? null, usage: line.message.usage ?? null, ts: line.timestamp ?? null, text: '', xb };
115 }116 }
116 const blocks = Array.isArray(line.message.content) ? line.message.content : [];117 const blocks = Array.isArray(line.message.content) ? line.message.content : [];
Rpackages/insight/src/app/api/settings/cannbay-auth/route.ts→packages/insight/src/app/api/settings/lake-auth/route.ts+3-3
@@ -7,7 +7,7 @@
7// See LICENSE in the root of the software repository for the full text of the License.7// See LICENSE in the root of the software repository for the full text of the License.
8 8 
9// 设置页·数据之湖凭据管理 API(服务端存储取代浏览器 localStorage——cpx 与9// 设置页·数据之湖凭据管理 API(服务端存储取代浏览器 localStorage——cpx 与
10-// insight 同机共读同一份 cannbay-auth.json,上传/下载两侧统一凭据源)。10+// insight 同机共读同一份 lake-auth.json,上传/下载两侧统一凭据源)。
11// 响应红线:任何分支都不得回传令牌明文(只回脱敏尾段);错误消息过11// 响应红线:任何分支都不得回传令牌明文(只回脱敏尾段);错误消息过
12// redactRemoteSecrets。写入面:PUT 保存(地址按 normalizeAuthKey 键控),12// redactRemoteSecrets。写入面:PUT 保存(地址按 normalizeAuthKey 键控),
13// DELETE 清除(缺省 = 当前地址;?all=1 全部)。13// DELETE 清除(缺省 = 当前地址;?all=1 全部)。
@@ -16,8 +16,8 @@ import { spawnSync } from 'node:child_process';
16import {16import {
17 clearStoredCredentials, listStoredRemotes, maskToken, normalizeAuthKey,17 clearStoredCredentials, listStoredRemotes, maskToken, normalizeAuthKey,
18 setStoredCredentials,18 setStoredCredentials,
19-} from '@/lib/cannbay2/auth';19+} from '@/lib/lake/auth';
20-import { FORBIDDEN_ADDRESS_CHARS } from '@/lib/cannbay2/remote';20+import { FORBIDDEN_ADDRESS_CHARS } from '@/lib/lake/remote';
21 21 
22export const maxDuration = 60;22export const maxDuration = 60;
23 23 
@@ -0,0 +1,56 @@
1+// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd.
2+// This program is free software: you can redistribute it and/or modify it under the terms and conditions of
3+// CANN Open Software License Agreement Version 2.0 (the "License").
4+// Please refer to the License for details. You may not use this file except in compliance with the License.
5+// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,
6+// INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
7+// See LICENSE in the root of the software repository for the full text of the License.
8+ 
9+// 设置页·数据之湖远端偏好 API(服务端 lake-config.json:address/branch/
10+// directory——单源架构的唯一配置源,Web/Viewer/CLI/cpx 同机共读)。GET 回
11+// 完整偏好(非凭据,最小披露),PUT/DELETE 全量写/清。
12+import { NextRequest, NextResponse } from 'next/server';
13+import {
14+ clearServerLakeConfig, readServerLakeConfig, writeServerLakeConfig,
15+} from '@/lib/lake/server-config';
16+import { FORBIDDEN_ADDRESS_CHARS, validateBranch, normalizeDirectory } from '@/lib/lake/remote';
17+ 
18+export async function GET() {
19+ const cfg = readServerLakeConfig();
20+ return NextResponse.json({
21+ configured: cfg != null,
22+ address: cfg?.address ?? '',
23+ branch: cfg?.branch ?? '',
24+ directory: cfg?.directory ?? '',
25+ });
26+}
27+ 
28+export async function PUT(request: NextRequest) {
29+ let payload: { address: string; branch?: string; directory?: string };
30+ try {
31+ const body = await request.json();
32+ const { address, branch, directory } = body as { address?: string; branch?: string; directory?: string };
33+ const addr = address?.trim();
34+ if (!addr) return NextResponse.json({ error: '缺少远端地址' }, { status: 400 });
35+ if (FORBIDDEN_ADDRESS_CHARS.test(addr)) return NextResponse.json({ error: '数据湖地址含非法字符' }, { status: 400 });
36+ // 分支/目录过同款白名单(无效值 400 指向字段,与 lake 路由一致);
37+ // 接收归一化返回值落盘——存储层即干净形态(strip 首尾空白/斜杠),
38+ // 消费方(resolveLakeRemote)不必各自再归一
39+ const branchValue = branch?.trim() ? validateBranch(branch) : undefined;
40+ const directoryValue = directory?.trim() ? normalizeDirectory(directory) : undefined;
41+ payload = { address: addr, branch: branchValue, directory: directoryValue };
42+ } catch (e) {
43+ return NextResponse.json({ error: e instanceof Error ? e.message : 'Invalid request' }, { status: 400 });
44+ }
45+ try {
46+ writeServerLakeConfig(payload);
47+ return NextResponse.json({ ok: true });
48+ } catch (e) {
49+ return NextResponse.json({ error: e instanceof Error ? e.message : 'Write failed' }, { status: 500 });
50+ }
51+}
52+ 
53+export async function DELETE() {
54+ clearServerLakeConfig();
55+ return NextResponse.json({ ok: true });
56+}
@@ -1,599 +0,0 @@
1-// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd.
2-// This program is free software, you can redistribute it and/or modify it under the terms and conditions of
3-// CANN Open Software License Agreement Version 2.0 (the "License").
4-// Please refer to the License for details. You should not use this file except in compliance with the License.
5-// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,
6-// INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE.
7-// See LICENSE in the root of the software repository for the full text of the License.
8- 
9-// 数据湖 git 镜像层(vendor 自 insight cannbay2/mirror.ts 的只读子集,决策
10-// §10-2 先冗余后收敛:仅列表读取,无上传链/物化/spread 写操作)。partial
11-// clone(--filter=blob:none)——列表 = ls-tree + 一次 git log walk + 一次
12-// cat-file --batch 批量 meta 读取(meta 为 KB 级小 blob,其余零 blob 下载)。
13-// 测试通过 CANNBAY2_REMOTE_URL / CANNBAY_VIEWER_CACHE_DIR 指向本地 bare 仓。
14-import { execSync, spawn } from 'node:child_process';
15-import { randomUUID } from 'node:crypto';
16-import fs from 'node:fs';
17-import os from 'node:os';
18-import path from 'node:path';
19-import { escapeRegExp, redactLakeSecrets, resolveLakeRemote, stripUrlCreds } from './remote';
20-import type { LakeRemote } from './remote';
21- 
22-export { resolveLakeRemote } from './remote';
23-export type { LakeRemote } from './remote';
24- 
25-// 双记录目录布局(与 cannbay2/mirror.ts 同构):捕获/主位占裸 <sid>/,原生
26-// 会话占 <sid>.native/(目录内文件仍以真实 sid 命名)。同一 sid 可各有一条。
27-export type RecordingClass = 'capture' | 'native';
28- 
29-const NATIVE_FOLDER_SUFFIX = '.native';
30- 
31-/** 仓内目录名 → (真实 sid, 记录类)。无 .native 后缀的目录一律视为捕获位。 */
32-export function parseSessionFolder(folder: string): { sid: string; recording: RecordingClass } {
33- return folder.endsWith(NATIVE_FOLDER_SUFFIX)
34- ? { sid: folder.slice(0, -NATIVE_FOLDER_SUFFIX.length), recording: 'native' }
35- : { sid: folder, recording: 'capture' };
36-}
37- 
38-/** 记录类 → 仓内目录名。 */
39-export function sessionFolderName(sid: string, recording: RecordingClass = 'capture'): string {
40- return recording === 'native' ? `${sid}${NATIVE_FOLDER_SUFFIX}` : sid;
41-}
42- 
43-// LFS 免疫(per-command 中和,镜像内 git 操作对"装没装 git-lfs"完全不敏感):
44-// 数据仓 .gitattributes 带 filter=lfs 行 + 客户机 gitconfig 挂着 lfs filter 时——
45-// checkout:孤儿指针触发 smudge 下载,远端 LFS 未启用 → 403 "project lfs not
46-// enabled" → fatal(客户现场实测:git lfs uninstall 清不掉 system/XDG 级
47-// 配置,靠改机器状态不可靠);smudge=cat → 指针原样落盘不下载
48-// add:clean=cat → 新文件永不指针化
49-// required=false → 即便 filter 配置异常也放行不炸
50-const LFS_NEUTRALIZE = '-c filter.lfs.process= -c filter.lfs.smudge=cat -c filter.lfs.clean=cat -c filter.lfs.required=false';
51- 
52-export function runGit(cmd: string, cwd: string, timeoutMs = 120_000, opts: { env?: NodeJS.ProcessEnv; input?: string } = {}): Buffer {
53- const neutral = cmd.startsWith('git ')
54- ? `git ${LFS_NEUTRALIZE} ${cmd.slice('git '.length)}`
55- : cmd;
56- try {
57- return execSync(neutral, {
58- cwd, stdio: 'pipe', timeout: timeoutMs, maxBuffer: 256 * 1024 * 1024,
59- env: opts.env ? { ...process.env, ...opts.env } : process.env,
60- input: opts.input,
61- });
62- } catch (e) {
63- const err = e as Error & { stderr?: Buffer };
64- const detail = [err.message, err.stderr?.toString('utf8') ?? ''].join('\n');
65- throw new Error(redactLakeSecrets(detail, resolveLakeRemote()));
66- }
67-}
68- 
69-function runGitText(cmd: string, cwd: string): string {
70- return runGit(cmd, cwd).toString();
71-}
72- 
73-// ── 镜像生命周期 ───────────────────────────────────────────────────────
74- 
75-// 跨进程初始化锁:next dev 多 worker / 与其它工具并存时,多进程会在同一
76-// cacheDir 上并发首拉镜像 → "destination path already exists and is not an
77-// empty directory"。锁文件 O_EXCL 创建,持锁者完成后删除;进程崩溃残留的
78-// 锁按 mtime 超龄击穿(>15min,克隆自身 600s 超时 + 余量)。
79-function mirrorLocallyUsable(dir: string, remote: LakeRemote): boolean {
80- try {
81- const originUrl = runGitText('git remote get-url origin', dir).trim();
82- if (stripUrlCreds(originUrl) !== stripUrlCreds(remote.pullUrl)) return false;
83- runGit(`git rev-parse --verify -q "refs/remotes/origin/${remote.branch}"`, dir);
84- return true;
85- } catch {
86- return false;
87- }
88-}
89- 
90-export function revParseMain(dir: string, remote: LakeRemote = resolveLakeRemote()): string | null {
91- try {
92- return runGitText(`git rev-parse origin/${remote.branch}`, dir).trim();
93- } catch {
94- return null;
95- }
96-}
97- 
98-// ── 异步镜像生命周期 + 进度追踪 ────────────────────────────────────────
99-// execSync 会阻塞整个事件循环(首拉克隆可达数分钟),期间任何请求都无法应答,
100-// 前端只能干等。克隆/拉取走异步 spawn,stderr 进度行(--progress 强制输出)
101-// 汇入全局进度快照,/api/lake-status 轮询据此展示"正在克隆 45%"。
102- 
103-export interface LakeProgress {
104- phase: 'idle' | 'fetch' | 'clone' | 'list';
105- detail: string;
106- since: number;
107-}
108- 
109-let lakeProgressState: LakeProgress = { phase: 'idle', detail: '', since: 0 };
110- 
111-function setLakePhase(phase: LakeProgress['phase'], detail = ''): void {
112- lakeProgressState = { phase, detail, since: Date.now() };
113-}
114- 
115-export function markLakePhase(phase: LakeProgress['phase'], detail = ''): void {
116- setLakePhase(phase, detail);
117-}
118- 
119-export function lakeProgress(): LakeProgress & { elapsedMs: number } {
120- return { ...lakeProgressState, elapsedMs: lakeProgressState.since ? Date.now() - lakeProgressState.since : 0 };
121-}
122- 
123-export function runGitAsync(cmd: string, cwd: string, timeoutMs: number, onStderr?: (line: string) => void): Promise<number> {
124- const neutral = cmd.startsWith('git ')
125- ? `git ${LFS_NEUTRALIZE} ${cmd.slice('git '.length)}`
126- : cmd;
127- return new Promise((resolve) => {
128- // detached:bash 自成进程组长。超时必须杀整组——只杀 bash 会把 git
129- // (孙进程)留成孤儿,继续写 tmp/镜像并与重试竞争。
130- const child = spawn('bash', ['-c', neutral], { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
131- let pending = '';
132- child.stderr.on('data', (chunk: Buffer) => {
133- pending += chunk.toString('utf8');
134- const lines = pending.split(/\r|\n/);
135- pending = lines.pop() ?? '';
136- for (const line of lines) {
137- const trimmed = line.trim();
138- if (trimmed) onStderr?.(trimmed);
139- }
140- });
141- const timer = setTimeout(() => {
142- try {
143- if (child.pid != null) process.kill(-child.pid, 'SIGKILL');
144- } catch { child.kill('SIGKILL'); }
145- }, timeoutMs);
146- child.on('close', (code) => {
147- clearTimeout(timer);
148- const rest = pending.trim();
149- if (rest) onStderr?.(rest);
150- resolve(code ?? -1);
151- });
152- child.on('error', () => {
153- clearTimeout(timer);
154- resolve(-1);
155- });
156- });
157-}
158- 
159-async function tryFetchMirrorAsync(dir: string, remote: LakeRemote): Promise<boolean> {
160- if (!fs.existsSync(path.join(dir, '.git'))) return false;
161- setLakePhase('fetch', `origin/${remote.branch}`);
162- const code = await runGitAsyncWithAuth(`git fetch --progress origin ${remote.branch}`, dir, remote.pullAuth, 120_000,
163- line => setLakePhase('fetch', redactLakeSecrets(line, remote)));
164- if (code === 0) { setLakePhase('idle'); return true; }
165- return mirrorLocallyUsable(dir, remote);
166-}
167- 
168-// 异步锁等待:同步版用 Atomics.wait 自旋会阻塞事件循环,这里改轮询锁文件。
169-// 不变量(与 cannbay2 共用镜像目录后的关键约束):**锁绝不被跨 await 长持有**
170-// —— cannbay2 的同步镜像路径(withMirrorInitLock,Atomics.wait 真阻塞事件
171-// 循环)等这个锁时,若 viewer 把锁握在分钟级异步克隆上,克隆完成回调永远
172-// 无法派发 → 整进程假死到 15min 锁超龄。因此锁内只做快操作(fetch 验证 /
173-// rm+rename 交换,毫秒级),长克隆在锁外完成后再短暂持锁交换。
174-async function acquireInitLockAsync(lock: string): Promise<void> {
175- const STALE_MS = 15 * 60_000;
176- const deadline = Date.now() + STALE_MS;
177- for (;;) {
178- try {
179- fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
180- return;
181- } catch (e) {
182- if (e instanceof Error && (e as NodeJS.ErrnoException).code === 'EEXIST') {
183- try {
184- const stat = fs.statSync(lock);
185- if (Date.now() - stat.mtimeMs > STALE_MS) {
186- fs.rmSync(lock, { force: true });
187- continue;
188- }
189- } catch { /* 锁刚被删除 → 重试竞争 */ }
190- if (Date.now() > deadline) throw new Error(`cannbay viewer mirror init lock timeout: ${lock}`);
191- await new Promise(r => setTimeout(r, 500));
192- continue;
193- }
194- throw e;
195- }
196- }
197-}
198- 
199-function releaseInitLock(lock: string): void {
200- try {
201- const holder = fs.readFileSync(lock, 'utf8');
202- if (holder === String(process.pid)) fs.rmSync(lock, { force: true });
203- } catch { /* best-effort */ }
204-}
205- 
206-// ensure 单飞 + fetch TTL:ensureMirrorAsync 在每个 API 路径上(runs/notes/
207-// health 2s 轮询),不做节流会把每次筛选/轮询变成一次平台往返,并发
208-// 调用还会在同一 cacheDir 上踩 ref 锁。TTL 内的镜像视为新鲜,直接复用。
209-const FETCH_TTL_MS = 15_000;
210-const lastFreshAt = new Map<string, number>();
211-const inflightEnsure = new Map<string, Promise<string>>();
212- 
213-export async function ensureMirrorAsync(remote: LakeRemote = resolveLakeRemote()): Promise<string> {
214- const dir = path.resolve(remote.cacheDir);
215- if (fs.existsSync(path.join(dir, '.git')) && Date.now() - (lastFreshAt.get(dir) ?? 0) <= FETCH_TTL_MS) {
216- return dir;
217- }
218- const existing = inflightEnsure.get(dir);
219- if (existing) return existing;
220- const run = ensureMirrorUncached(dir, remote)
221- .finally(() => { inflightEnsure.delete(dir); });
222- inflightEnsure.set(dir, run);
223- return run;
224-}
225- 
226-// 锁外长克隆 → 短暂持锁交换。并发冷启动(viewer 本身 / cannbay2 同步路径)
227-// 可能各自克隆一份 tmp——浪费一次克隆但安全:交换由锁串行,后换者胜;
228-// 交换前的「他人是否已交付」判定用本地检查(mirrorLocallyUsable,纯本地 git
229-// 读、毫秒级),不用 fetch(网络操作进锁定区等于换一种方式长持锁)。
230- 
231-/**
232- * Run a git command with a one-shot credential helper (401 challenge-response
233- * auth) — private-repo pull support. URLs never carry embedded credentials;
234- * the helper script lives in a 0700 temp dir and is removed afterwards.
235- * auth=null runs the command as-is.
236- */
237-async function runGitAsyncWithAuth(
238- cmd: string,
239- cwd: string,
240- auth: LakeRemote['pullAuth'],
241- timeoutMs: number,
242- onStderr?: (line: string) => void,
243-): Promise<number> {
244- if (!auth) return runGitAsync(cmd, cwd, timeoutMs, onStderr);
245- if (/[\r\n]/.test(auth.username) || /[\r\n]/.test(auth.password)) {
246- throw new Error('git credentials contain newline (invalid for credential helper protocol)');
247- }
248- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'insight-lake-auth-'));
249- try {
250- const helper = path.join(tmp, 'helper.sh');
251- fs.writeFileSync(
252- helper,
253- `#!/bin/sh\ncat <<'LAKEEOF'\nusername=${auth.username}\npassword=${auth.password}\nLAKEEOF\n`,
254- { mode: 0o700 },
255- );
256- const prefix = `-c credential.helper= -c credential.helper="${helper}"`;
257- const wrapped = cmd.startsWith('git ') ? `git ${prefix} ${cmd.slice('git '.length)}` : cmd;
258- return await runGitAsync(wrapped, cwd, timeoutMs, onStderr);
259- } finally {
260- fs.rmSync(tmp, { recursive: true, force: true });
261- }
262-}
263- 
264-async function cloneAndSwapAsync(dir: string, remote: LakeRemote): Promise<void> {
265- const target = path.resolve(dir);
266- const tmp = `${target}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
267- fs.rmSync(tmp, { recursive: true, force: true });
268- try {
269- setLakePhase('clone', '连接远端…');
270- const code = await runGitAsyncWithAuth(
271- `git clone --no-local --filter=blob:none --no-checkout --progress "${remote.pullUrl}" "${tmp}"`,
272- path.dirname(target), remote.pullAuth, 600_000,
273- line => setLakePhase('clone', redactLakeSecrets(line, remote)),
274- );
275- if (code !== 0) throw new Error(`git clone 失败(exit ${code}),详见 /api/lake-status`);
276- const lock = `${dir}.lock`;
277- fs.mkdirSync(path.dirname(lock), { recursive: true });
278- await acquireInitLockAsync(lock);
279- try {
280- if (!mirrorLocallyUsable(dir, remote)) {
281- fs.rmSync(target, { recursive: true, force: true });
282- fs.mkdirSync(path.dirname(target), { recursive: true });
283- fs.renameSync(tmp, target);
284- } else {
285- fs.rmSync(tmp, { recursive: true, force: true }); // 他人已交付可用镜像
286- }
287- } finally {
288- releaseInitLock(lock);
289- }
290- setLakePhase('idle');
291- } catch (e) {
292- fs.rmSync(tmp, { recursive: true, force: true });
293- throw e;
294- }
295-}
296- 
297-async function ensureMirrorUncached(dir: string, remote: LakeRemote): Promise<string> {
298- if (await tryFetchMirrorAsync(dir, remote)) {
299- lastFreshAt.set(dir, Date.now());
300- return dir;
301- }
302- // 锁定区只做本地判定(毫秒级):fetch 失败后他人是否已克隆好镜像;
303- // 网络 fetch 已在上方无锁完成,长克隆在 cloneAndSwap 锁外进行
304- const lock = `${dir}.lock`;
305- fs.mkdirSync(path.dirname(lock), { recursive: true });
306- let usable = false;
307- await acquireInitLockAsync(lock);
308- try {
309- usable = mirrorLocallyUsable(dir, remote);
310- } finally {
311- releaseInitLock(lock);
312- }
313- if (!usable) {
314- await cloneAndSwapAsync(dir, remote);
315- }
316- lastFreshAt.set(dir, Date.now());
317- return dir;
318-}
319- 
320-// ── ls-tree 输出解析(quotepath 防线)──────────────────────────────────
321-// git 默认 core.quotepath 会把含非 ASCII 的整条路径加引号转义——所有
322-// ls-tree 一律 -z(NUL 分隔,路径永不转义/加引号);decodeGitPath 兜底解码。
323- 
324-function decodeGitPath(p: string): string {
325- if (!p.startsWith('"')) return p;
326- try {
327- return JSON.parse(p) as string;
328- } catch {
329- return p.replace(/^"|"$/g, '');
330- }
331-}
332- 
333-function splitLsTreeEntry(rec: string): { meta: string; path: string } | null {
334- const tab = rec.indexOf('\t');
335- if (tab < 0) return null;
336- return { meta: rec.slice(0, tab), path: decodeGitPath(rec.slice(tab + 1)) };
337-}
338- 
339-function lsTreeFiles(dir: string): string[] {
340- const out = runGit(`git ls-tree -r -z --name-only origin/${resolveLakeRemote().branch}`, dir);
341- return out.toString('utf8').split('\0').map(decodeGitPath).filter(l => l.length > 0);
342-}
343- 
344-function lsTreeTopDirs(dir: string, remote: LakeRemote): string[] {
345- const out = runGit(`git ls-tree -z origin/${remote.branch}`, dir);
346- const names: string[] = [];
347- for (const rec of out.toString('utf8').split('\0')) {
348- const entry = splitLsTreeEntry(rec);
349- if (!entry) continue;
350- if (/^\d+\s+tree\s+[0-9a-f]+$/.test(entry.meta)) {
351- names.push(entry.path.replace(/\/$/, ''));
352- }
353- }
354- return names;
355-}
356- 
357-// ── 列表(零 blob)─────────────────────────────────────────────────────
358- 
359-export interface LakeSession {
360- sid: string;
361- recording: RecordingClass;
362- fileCount: number;
363- commitTime: string;
364- commitSubject: string;
365- framework: string | null;
366- ccVersion: string | null;
367- producer: string | null;
368- workflow: string | null;
369- opGen: 'success' | 'failure' | null;
370- /** 上传时刻写进 meta 的指标快照(spec §4.4 可选字段);存量旧 meta 为 null */
371- startTime: string | null;
372- durationMs: number | null;
373- /** 活跃时长快照(spec §4.4 可选字段,同上传时刻写入);旧 meta 为 null */
374- activeMs: number | null;
375- totalTokens: number | null;
376- /** 会话主模型(首条 assistant 轮),同指标快照一道写入 meta */
377- model: string | null;
378- paradigm: string | null;
379- isBaseline: boolean;
380-}
381- 
382-// 单个子进程 `cat-file --batch` 流式读取全部 <paradigm>/<sid>/<sid>.meta.json。
383-// meta 都是 KB 级小 blob,批量一次协议协商拉取;已缓存的 blob 纯本地读。
384-// 异步 spawn:冷镜像首列时按需拉取 meta blob 可达秒级(劣化链路更久),
385-// 同步等待会冻住整个事件循环(LakeProgressView 停更、所有路由无响应)。
386-interface SessionMeta {
387- framework: string | null;
388- ccVersion: string | null;
389- producer: string | null;
390- workflow: string | null;
391- opGen: 'success' | 'failure' | null;
392- startTime: string | null;
393- durationMs: number | null;
394- activeMs: number | null;
395- totalTokens: number | null;
396- model: string | null;
397-}
398- 
399-// 双目录形态(同 sid 双记录):裸 <sid>/<sid>.meta.json(捕获/主位)与
400-// <sid>.native/<sid>.meta.json(原生——目录带后缀、文件用真实 sid)。
401-function wantedMetaEntries(treeOut: string): Array<{ folder: string; oid: string }> {
402- const barePattern = /^[^/]+\/(?:baseline\/)?([^/]+)\/\1\.meta\.json$/;
403- const nativePattern = /^[^/]+\/(?:baseline\/)?([^/]+)\.native\/\1\.meta\.json$/;
404- const wanted: Array<{ folder: string; oid: string }> = [];
405- for (const rec of treeOut.split('\0')) {
406- const entry = splitLsTreeEntry(rec);
407- if (!entry) continue;
408- const oidMatch = entry.meta.match(/^[0-9]+\s+blob\s+([0-9a-f]+)$/);
409- if (!oidMatch) continue;
410- // 裸形态捕获组 = 目录名;.native 形态捕获组 = 去后缀 sid,补回后缀才是目录名(map 键)
411- const bare = entry.path.match(barePattern);
412- const native = bare ? null : entry.path.match(nativePattern);
413- if (bare) wanted.push({ folder: bare[1], oid: oidMatch[1] });
414- else if (native) wanted.push({ folder: `${native[1]}${NATIVE_FOLDER_SUFFIX}`, oid: oidMatch[1] });
415- }
416- return wanted;
417-}
418- 
419-// 输出格式(每个请求一条):<oid> SP <type> SP <size>\n<content>\n\n
420-function parseSessionMetas(buf: Buffer, wanted: Array<{ folder: string; oid: string }>): Map<string, SessionMeta> {
421- const out = new Map<string, SessionMeta>();
422- let offset = 0;
423- const text = (start: number, end: number): string => buf.toString('utf8', start, end);
424- for (const w of wanted) {
425- if (offset >= buf.length) break;
426- const headerEnd = buf.indexOf(0x0a, offset);
427- if (headerEnd < 0) break;
428- const header = text(offset, headerEnd);
429- const sizeMatch = header.match(/^\S+\s+\S+\s+(\d+)$/);
430- if (!sizeMatch) { offset = headerEnd + 1; continue; }
431- const size = parseInt(sizeMatch[1], 10);
432- const body = text(headerEnd + 1, headerEnd + 1 + size);
433- offset = headerEnd + 1 + size + 1;
434- try {
435- const meta = JSON.parse(body) as { x_cannbay?: { data?: { framework?: unknown; ccVersion?: unknown; producer?: unknown; workflow?: unknown; opGen?: unknown; startTime?: unknown; durationMs?: unknown; activeMs?: unknown; totalTokens?: unknown; model?: unknown } } };
436- const d = meta.x_cannbay?.data;
437- out.set(w.folder, {
438- framework: typeof d?.framework === 'string' ? d.framework : null,
439- ccVersion: typeof d?.ccVersion === 'string' ? d.ccVersion : null,
440- producer: typeof d?.producer === 'string' ? d.producer : null,
441- // workflow 版本(§10 决策 1 预留位):cpx 落地前恒 null,落地后零改造直显
442- workflow: typeof d?.workflow === 'string' ? d.workflow : null,
443- // opGen(可选字段,spec §4.4):只认 success/failure,其余值/缺省一律未标注
444- opGen: d?.opGen === 'success' || d?.opGen === 'failure' ? d.opGen : null,
445- // 指标快照(可选字段,spec §4.4,上传时刻写入):存量旧 meta 缺省为 null
446- startTime: typeof d?.startTime === 'string' ? d.startTime : null,
447- model: typeof d?.model === 'string' && d.model ? d.model : null,
448- durationMs: typeof d?.durationMs === 'number' && Number.isFinite(d.durationMs) ? d.durationMs : null,
449- activeMs: typeof d?.activeMs === 'number' && Number.isFinite(d.activeMs) ? d.activeMs : null,
450- totalTokens: typeof d?.totalTokens === 'number' && Number.isFinite(d.totalTokens) ? d.totalTokens : null,
451- });
452- } catch { /* 截断/畸形 meta → null,不拖垮整个列表 */ }
453- }
454- return out;
455-}
456- 
457-// 缺失 blob 预取分块:单次 fetch 的 OID 参数长度上限(与 cannbay2 同值)
458-const FETCH_OID_CHUNK = 200;
459- 
460-async function readSessionMetas(dir: string): Promise<Map<string, SessionMeta>> {
461- try {
462- const treeOut = runGit(`git ls-tree -r -z origin/${resolveLakeRemote().branch}`, dir).toString('utf8');
463- const wanted = wantedMetaEntries(treeOut);
464- if (wanted.length === 0) return new Map();
465- // 缺失 meta blob 批量预取:懒取是逐对象一次协商,冷镜像千级缺失时批量读
466- // 会被 120s 超时截断、指标列残缺(cannbay2 实测 2370 缺失懒取 ≈42min vs
467- // 批量 ~10s)。本地对象清单是纯本地读;fetch 走异步不阻塞事件循环,
468- // 失败(服务端拒按 OID fetch 等)自然回退逐对象懒取。
469- const local = new Set(
470- runGit(`git cat-file --batch-all-objects --batch-check='%(objectname)'`, dir)
471- .toString('utf8').split('\n').map(l => l.trim()).filter(Boolean),
472- );
473- const missing = wanted
474- .filter(w => !local.has(w.oid))
475- .map(w => w.oid)
476- .filter(o => /^[0-9a-f]{40,64}$/.test(o)); // 拼 shell 命令前的白名单校验
477- for (let i = 0; i < missing.length; i += FETCH_OID_CHUNK) {
478- await runGitAsync(`git fetch --quiet origin ${missing.slice(i, i + FETCH_OID_CHUNK).join(' ')}`, dir, 300_000);
479- }
480- const buf = await new Promise<Buffer>(resolve => {
481- const child = spawn('git', ['cat-file', '--batch'], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] });
482- const chunks: Buffer[] = [];
483- const timer = setTimeout(() => child.kill('SIGKILL'), 120_000);
484- child.stdout.on('data', (chunk: Buffer) => chunks.push(chunk));
485- child.on('close', () => { clearTimeout(timer); resolve(Buffer.concat(chunks)); });
486- child.on('error', () => { clearTimeout(timer); resolve(Buffer.alloc(0)); });
487- child.stdin.on('error', () => { /* EPIPE:进程早退,close 兜底 resolve */ });
488- child.stdin.end(wanted.map(w => `${w.oid}\n`).join(''));
489- });
490- return parseSessionMetas(buf, wanted);
491- } catch { /* meta 批量读失败 → 指标列降级为空,列表主体不受影响 */ }
492- return new Map();
493-}
494- 
495-/** ls-tree + 一次 git log walk + 一次批量 meta 读取 → 全部运行及提交元数据。
496- * 布局(spec §2):<paradigm>/<sid>/(范式目录即注册表)、<paradigm>/baseline/<sid>/、
497- * <paradigm>/<sid>.native/(原生会话双记录位,目录内文件用真实 sid 命名)、
498- * sessions/<sid>/(存量区 → paradigm null,不入范式视图)。 */
499-export async function listSessions(remote: LakeRemote = resolveLakeRemote()): Promise<LakeSession[]> {
500- return readLakeSessions(await ensureMirrorAsync(remote), remote);
501-}
502- 
503-/** 镜像已就绪前提下的纯读取段(ensure 由调用方完成,避免一次构建三连 fetch)。 */
504-export async function readLakeSessions(dir: string, remote: LakeRemote): Promise<LakeSession[]> {
505- markLakePhase('list', '读取范式与会话清单…');
506- const mainRev = revParseMain(dir, remote);
507- if (!mainRev) { markLakePhase('idle'); return []; }
508- 
509- const filePattern = /^([^/]+)\/(baseline\/)?([^/]+)\//;
510- const treeFiles = lsTreeFiles(dir);
511- const fileCount = new Map<string, number>();
512- const sidLoc = new Map<string, { root: string; baseline: boolean }>();
513- for (const line of treeFiles) {
514- const m = line.match(filePattern);
515- if (m) {
516- // m 捕获的是目录名(folder)——同 sid 双记录下裸目录与 .native 是两个
517- // 目录,各自一条目;真实 sid 由 parseSessionFolder 剥后缀还原
518- const folder = m[3];
519- fileCount.set(folder, (fileCount.get(folder) ?? 0) + 1);
520- sidLoc.set(folder, { root: m[1], baseline: m[2] != null });
521- }
522- }
523- 
524- // 一次 log walk:位置限定 sid → 上传 commit 的时间 + subject(log 新→旧,
525- // 持续覆写——walk 结束留存该位置最老一次触碰,即当前位置的首传 commit)。
526- // 单遍分组 O(files)。-c core.quotepath=off:log --name-only 的中文路径同样
527- // 默认转义;%x1f 分隔时间与 subject(%s 单行,含上传时填的 提交人/内容描述/备注)。
528- // 归属取「当前位置的首传」而非「最后触碰」或「全局首传」:
529- // - 最后触碰:任何维护提交(viewer 记录/标注、meta 指标回填、baseline 搬移)
530- // 都会把描述列顶成维护信息、开始时间平移;
531- // - 全局首传:跨位置迁移(cpx 自动上传 others/ → 人工范式上传)会把归属
532- // 顶成自动上传的 bot 身份(cann-robot),而不是把会话放进范式的人。
533- // notes(viewer): 前缀跳过保留(防御性:正常时序下它不可能是首传)。
534- const sidCommit = new Map<string, { time: string; subject: string }>();
535- try {
536- const logOut = runGitText(
537- `git -c core.quotepath=off log --pretty=format:%x00%aI%x1f%s --name-only origin/${remote.branch}`,
538- dir,
539- );
540- for (const record of logOut.split('\x00')) {
541- const lines = record.split('\n').filter(l => l.trim());
542- if (lines.length === 0) continue;
543- const sep = lines[0].indexOf('\x1f');
544- const time = sep < 0 ? lines[0] : lines[0].slice(0, sep);
545- const subject = sep < 0 ? '' : lines[0].slice(sep + 1);
546- if (subject.startsWith('notes(viewer):')) continue;
547- for (const file of lines.slice(1)) {
548- const m = file.match(filePattern);
549- if (m) sidCommit.set(`${m[1]}/${m[2] ?? ''}${m[3]}`, { time, subject });
550- }
551- }
552- } catch { /* 无历史时列表退化为纯文件名 */ }
553- 
554- const entries: LakeSession[] = [];
555- const metas = await readSessionMetas(dir);
556- for (const [folder, count] of fileCount) {
557- const { sid, recording } = parseSessionFolder(folder);
558- const meta = metas.get(folder);
559- const loc = sidLoc.get(folder) ?? { root: 'sessions', baseline: false };
560- const upload = sidCommit.get(`${loc.root}/${loc.baseline ? 'baseline/' : ''}${folder}`);
561- entries.push({
562- sid,
563- recording,
564- fileCount: count,
565- commitTime: upload?.time ?? '',
566- commitSubject: upload?.subject ?? '',
567- framework: meta?.framework ?? null,
568- ccVersion: meta?.ccVersion ?? null,
569- producer: meta?.producer ?? null,
570- workflow: meta?.workflow ?? null,
571- opGen: meta?.opGen ?? null,
572- startTime: meta?.startTime ?? null,
573- durationMs: meta?.durationMs ?? null,
574- activeMs: meta?.activeMs ?? null,
575- totalTokens: meta?.totalTokens ?? null,
576- model: meta?.model ?? null,
577- paradigm: loc.root,
578- isBaseline: loc.baseline,
579- });
580- }
581- entries.sort((a, b) => (b.commitTime || '').localeCompare(a.commitTime || ''));
582- markLakePhase('idle');
583- return entries;
584-}
585- 
586-/** 顶层范式目录清单:目录即范式,排除存量区 sessions/。 */
587-export async function listParadigmDirs(remote: LakeRemote = resolveLakeRemote()): Promise<string[]> {
588- const dir = await ensureMirrorAsync(remote);
589- return readParadigmDirs(dir, remote);
590-}
591- 
592-export function readParadigmDirs(dir: string, remote: LakeRemote): string[] {
593- if (!revParseMain(dir, remote)) return [];
594- return lsTreeTopDirs(dir, remote)
595- .filter(name => name !== 'sessions')
596- .sort();
597-}
598- 
599-export { escapeRegExp };
@@ -1,139 +0,0 @@
1-// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd.
2-// This program is free software: you can redistribute it and/or modify it under the terms and conditions of
3-// CANN Open Software License Agreement Version 2.0 (the "License").
4-// Please refer to the License for details. You should not use this file except in compliance with the License.
5-// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,
6-// INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE.
7-// See LICENSE in the root of the software repository for the full text of the License.
8- 
9-// 数据湖远端解析(vendor 自 insight cannbay2/remote.ts 的只读子集,决策
10-// §10-2 先冗余后收敛):env > 硬编码默认。镜像目录默认与 insight cannbay2
11-// 共用(见 defaultCacheDir),env 口径一致。唯一写路径 = 实验记录追加
12-// (notes);push 凭据不再内置(共享令牌已出源)——统一走 cannbay2/auth
13-// 解析链(env 完整 URL > env 分离对 > cannbay-auth.json 已存凭据,与
14-// insight/cpx 同一份存储)。分支/目录会拼进 shell git 命令,白名单校验与
15-// insight 同款;错误消息出边界前必须 redact(execSync 失败携带完整命令行)。
16-import path from 'node:path';
17-import { createHash } from 'node:crypto';
18-import { parseUrlCreds, resolvePushAuth } from '@/lib/cannbay2/auth';
19- 
20-export { parseUrlCreds } from '@/lib/cannbay2/auth';
21- 
22-export const CANNBAY2_PULL_URL = process.env.CANNBAY2_REMOTE_URL
23- ?? 'https://example.com/cannbay-lake.git';
24-export const CANNBAY2_BRANCH = process.env.CANNBAY2_BRANCH ?? 'main';
25- 
26-export interface LakeRemote {
27- pullUrl: string;
28- /** 无凭据 push 地址:凭据走 credential helper(pushAuth),绝不内嵌 URL */
29- pushUrl: string;
30- /** push 凭据(env URL / env 分离对 / 已存凭据解析而来);null = 匿名推 */
31- pushAuth: PushAuth | null;
32- /**
33- * Pull credentials for private remotes: applied to clone/fetch via a
34- * one-shot credential helper (same 401-challenge channel as push). Null =
35- * pull anonymously.
36- */
37- pullAuth: PushAuth | null;
38- branch: string;
39- cacheDir: string;
40-}
41- 
42-const FORBIDDEN_ADDRESS_CHARS = /["'`$;&|\n\r]/;
43- 
44-export function stripUrlCreds(url: string): string {
45- return url.replace(/^(https?:\/\/)[^/@]+@/i, '$1');
46-}
47- 
48-export function normalizeGitAddress(address: string): string {
49- const trimmed = address.trim();
50- if (!trimmed) throw new Error('无效的数据湖地址');
51- if (FORBIDDEN_ADDRESS_CHARS.test(trimmed)) throw new Error('数据湖地址含非法字符');
52- if (/^https?:\/\//i.test(trimmed)) {
53- let u: URL;
54- try {
55- u = new URL(trimmed);
56- } catch {
57- throw new Error(`无效的数据湖地址: "${trimmed.slice(0, 80)}"`);
58- }
59- // 别名 host 不服务 git http(与 cannbay2 同款改写):不改写则 clone 失败;
60- // 共用镜像场景下还会因 origin 不匹配被判"镜像不可用"触发破坏性重克隆
61- if (u.host === 'ai.gitcode.com') u.host = 'gitcode.com';
62- u.username = '';
63- u.password = '';
64- return u.toString().replace(/\/+$/, '');
65- }
66- return trimmed.replace(/\/+$/, '');
67-}
68- 
69-export function validateBranch(branch: string): string {
70- const b = branch.trim();
71- if (!/^[\w.-]+$/.test(b)) throw new Error(`无效分支名: "${branch.slice(0, 40)}"`);
72- return b;
73-}
74- 
75-// 白名单段含 `.`(正则元字符):拼 ls-tree/log 正则前必须转义
76-export function escapeRegExp(s: string): string {
77- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
78-}
79- 
80-function defaultCacheDir(): string {
81- // path.resolve 归一为绝对路径(相对 env 值按进程 cwd 单次解析,与 git 目标
82- // 参数一致)。默认与 insight cannbay2 共用同一镜像目录——viewer 并入单进程
83- // 后「两进程并发 fetch 同一 cacheDir」的隔离理由不再成立,且两侧初始化锁
84- // 同名同语义(<dir>.lock + wx + 15min 击穿)、克隆均持锁走 tmp+rename,
85- // 冷启动互斥成立;session 大文件因此只落一处(cannbay2 按需物化)。
86- // CANNBAY_VIEWER_CACHE_DIR 仍可强制独立镜像(测试隔离用)。
87- return path.resolve(
88- process.env.CANNBAY_VIEWER_CACHE_DIR
89- ?? process.env.CANNBAY2_CACHE_DIR
90- ?? path.join(process.cwd(), 'tmp', 'cannbay2-cache'),
91- );
92-}
93- 
94-// push 地址校验(允许带凭据形态进解析,但不能直接当 URL 用——见 pushMirror 注释)
95-function normalizePushUrl(address: string): string {
96- const trimmed = address.trim();
97- if (FORBIDDEN_ADDRESS_CHARS.test(trimmed)) throw new Error('数据湖 push 地址含非法字符');
98- return trimmed.replace(/\/+$/, '');
99-}
100- 
101-/** push 凭据(env 完整 URL 内嵌凭据 / env 分离对 / cannbay-auth.json);
102- * null = 匿名推(本地 bare 等) */
103-export interface PushAuth { username: string; password: string }
104- 
105-export function resolveLakeRemote(): LakeRemote {
106- const pullUrl = normalizeGitAddress(CANNBAY2_PULL_URL);
107- // push 目标:env CANNBAY2_PUSH_URL 覆写(credless 部分)> pull 地址;
108- // 凭据经 cannbay2/auth 解析链(测试用 env 覆写本地 bare 同一条路)
109- const pushOverride = process.env.CANNBAY2_PUSH_URL?.trim();
110- const { credless, auth } = pushOverride
111- ? parseUrlCreds(normalizePushUrl(pushOverride))
112- : { credless: pullUrl, auth: resolvePushAuth(pullUrl) };
113- const branch = validateBranch(CANNBAY2_BRANCH);
114- // 镜像目录按远端 URL+分支哈希(与 insight cannbay2 同公式 → 默认仓共用
115- // 同一镜像,大文件只落一处):历史不哈希直用缓存根,默认仓迁址后旧镜像
116- // origin 仍指旧仓、fetch 照样成功——列表会静默读错误仓库(2026-09-22
117- // 迁址实测),且与 insight 修复后的哈希目录分叉
118- const cacheDir = path.join(
119- defaultCacheDir(),
120- createHash('sha1').update(`${pullUrl}|${branch}`).digest('hex').slice(0, 12),
121- );
122- return {
123- pullUrl,
124- pushUrl: credless,
125- pushAuth: auth,
126- pullAuth: pushOverride ? resolvePushAuth(pullUrl) : auth,
127- branch,
128- cacheDir,
129- };
130-}
131- 
132-// execSync 失败的 err.message/stderr 含完整命令行(带凭据 URL)——出路由
133-// 边界前必须过这里
134-export function redactLakeSecrets(message: string, remote: LakeRemote): string {
135- return message
136- .split(remote.pullUrl).join(stripUrlCreds(remote.pullUrl))
137- .split(remote.pushUrl).join(stripUrlCreds(remote.pushUrl))
138- .replace(/https:\/\/[^/\s"']+@/g, 'https://<creds>@');
139-}
@@ -1,225 +1,14 @@
1// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd.1// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd.
2-// This program is free software, you can redistribute it and/or modify it under the terms and conditions of2+// This program is free software; you can redistribute it and/or modify it under the terms and conditions of
3// CANN Open Software License Agreement Version 2.0 (the "License").3// CANN Open Software License Agreement Version 2.0 (the "License").
4// Please refer to the License for details. You may not use this file except in compliance with the License.4// Please refer to the License for details. You may not use this file except in compliance with the License.
5// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,5// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,
6// INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.6// INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
7// See LICENSE in the root of the software repository for the full text of the License.7// See LICENSE in the root of the software repository for the full text of the License.
8 8 
9-'use client';9+// 旧路由兼容(平台化更名 lake_viewer):保留 redirect 防外链/书签断裂
10+import { redirect } from 'next/navigation';
10 11 
11-import { Suspense, useEffect, useMemo, useState } from 'react';12+export default function LegacyViewerPage(): never {
12-import Link from 'next/link';13+ redirect('/lake_viewer');
13-import { useRouter, useSearchParams } from 'next/navigation';
14-import { ArrowLeftIcon } from 'lucide-react';
15-import { ParadigmSidebar, type ParadigmSummaryView } from '@/app/cannbay_viewer/_components/ParadigmSidebar';
16-import { FilterBar, EMPTY_FILTERS, type Filters } from '@/app/cannbay_viewer/_components/FilterBar';
17-import { RunsTable, type RunItemView } from '@/app/cannbay_viewer/_components/RunsTable';
18-import { OpgenProgress } from '@/app/cannbay_viewer/_components/OpgenProgress';
19-import { LakeProgressView } from '@/app/cannbay_viewer/_components/LakeProgressView';
20-import { VERSION_DISPLAY } from '@/lib/version';
21-import { BAY_VIEWER_NAME } from '@/lib/branding';
22- 
23-interface RunsResponse {
24- paradigm: string;
25- total: number;
26- baselineSid: string | null;
27- insightReachable: boolean;
28- facets: { frameworks: string[]; ccVersions: string[] };
29- items: RunItemView[];
30-}
31- 
32-// 浏览器时区偏移(±HH:MM):日期筛选边界按用户本地时区解释,服务端不再
33-// 自行解读无时区日期(UTC 服务器上 +08 用户的「当天」会平移 8 小时)
34-function browserTzOffset(): string {
35- const minutes = new Date().getTimezoneOffset();
36- const sign = minutes <= 0 ? '+' : '-';
37- const abs = Math.abs(minutes);
38- return `${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}:${String(abs % 60).padStart(2, '0')}`;
39-}
40- 
41-function filtersToQuery(paradigm: string, f: Filters): string {
42- const p = new URLSearchParams();
43- p.set('paradigm', paradigm);
44- if (f.framework) p.set('framework', f.framework);
45- if (f.ccVersion) p.set('ccVersion', f.ccVersion);
46- if (f.opGen) p.set('opGen', f.opGen);
47- if (f.scored) p.set('scored', f.scored);
48- if (f.imported) p.set('imported', f.imported);
49- if (f.q.trim()) p.set('q', f.q.trim());
50- if (f.from) p.set('from', f.from);
51- if (f.to) p.set('to', f.to);
52- if (f.from || f.to) p.set('tz', browserTzOffset());
53- return p.toString();
54-}
55- 
56-function BrandMark({ size = 22 }: { size?: number }) {
57- return (
58- <svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true">
59- <rect x="0" y="0" width="32" height="32" rx="7" fill="#0f172a" />
60- <path
61- d="M4.5 16 C8 10.5 12 8.5 16 8.5 C20 8.5 24 10.5 27.5 16 C24 21.5 20 23.5 16 23.5 C12 23.5 8 21.5 4.5 16 Z"
62- fill="none"
63- stroke="#22d3ee"
64- strokeWidth="2"
65- strokeLinejoin="round"
66- />
67- <circle cx="16" cy="16" r="4.2" fill="none" stroke="#a78bfa" strokeWidth="2" />
68- <circle cx="16" cy="16" r="1.6" fill="#a78bfa" />
69- </svg>
70- );
71-}
72- 
73-function ViewerPage() {
74- const router = useRouter();
75- const searchParams = useSearchParams();
76- const filters = useMemo<Filters>(() => ({
77- framework: searchParams.get('framework') ?? '',
78- ccVersion: searchParams.get('ccVersion') ?? '',
79- opGen: (searchParams.get('opGen') as Filters['opGen']) ?? '',
80- scored: (searchParams.get('scored') as Filters['scored']) ?? '',
81- imported: (searchParams.get('imported') as Filters['imported']) ?? '',
82- q: searchParams.get('q') ?? '',
83- from: searchParams.get('from') ?? '',
84- to: searchParams.get('to') ?? '',
85- }), [searchParams]);
86- 
87- const [paradigms, setParadigms] = useState<ParadigmSummaryView[] | null>(null);
88- const selected = searchParams.get('paradigm') ?? paradigms?.[0]?.name ?? null;
89- 
90- const [result, setResult] = useState<{ query: string; data: RunsResponse | null; error: string | null } | null>(null);
91- const [refreshKey, setRefreshKey] = useState(0);
92- 
93- const query = selected ? filtersToQuery(selected, filters) : '';
94- const runs = result?.query === query ? result.data : null;
95- const error = result?.query === query ? result.error : null;
96- 
97- useEffect(() => {
98- let cancelled = false;
99- void fetch('/cannbay_viewer/api/paradigms').then(r => r.json()).then((d: { items?: ParadigmSummaryView[] }) => {
100- if (!cancelled) setParadigms(d.items ?? []);
101- }).catch(() => {
102- if (!cancelled) setParadigms([]);
103- });
104- return () => { cancelled = true; };
105- }, []);
106- 
107- useEffect(() => {
108- if (!query) return;
109- let cancelled = false;
110- void (async () => {
111- try {
112- const res = await fetch(`/cannbay_viewer/api/runs?${query}`);
113- const data = await res.json() as RunsResponse & { error?: string };
114- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
115- if (!cancelled) setResult({ query, data, error: null });
116- } catch (e) {
117- if (!cancelled) setResult({ query, data: null, error: e instanceof Error ? e.message : String(e) });
118- }
119- })();
120- return () => { cancelled = true; };
121- }, [query, refreshKey]);
122- 
123- const importRun = async (sid: string, recording: 'capture' | 'native'): Promise<string | null> => {
124- try {
125- const res = await fetch('/cannbay_viewer/api/runs/import', {
126- method: 'POST',
127- headers: { 'content-type': 'application/json' },
128- body: JSON.stringify({ sid, recording }),
129- });
130- if (!res.ok) {
131- const data = await res.json().catch(() => ({})) as { error?: string };
132- return data.error ?? `HTTP ${res.status}`;
133- }
134- setRefreshKey(k => k + 1);
135- return null;
136- } catch (e) {
137- return e instanceof Error ? e.message : String(e);
138- }
139- };
140- 
141- const updateUrl = (patch: Partial<Filters> & { paradigm?: string }) => {
142- const p = new URLSearchParams(searchParams);
143- const next = { ...filters, ...patch };
144- for (const key of Object.keys(EMPTY_FILTERS) as Array<keyof Filters>) {
145- const v = next[key];
146- if (v) p.set(key, v); else p.delete(key);
147- }
148- if (patch.paradigm) p.set('paradigm', patch.paradigm);
149- router.replace(`/cannbay_viewer/?${p.toString()}`, { scroll: false });
150- };
151- 
152- const loadingView = !error && !runs && !!selected;
153- 
154- return (
155- <div className="flex h-screen flex-col">
156- <header className="flex items-center justify-between border-b border-neutral-200 bg-white px-4 py-2.5">
157- <div className="flex items-baseline gap-3">
158- <div className="flex items-center gap-2.5">
159- <BrandMark />
160- <h1 className="text-base font-semibold">{BAY_VIEWER_NAME}</h1>
161- </div>
162- <span className="text-xs text-neutral-400">{VERSION_DISPLAY}</span>
163- <span className="text-xs text-neutral-400">范式工作流实验管理</span>
164- </div>
165- <Link href="/" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border text-sm font-medium hover:bg-neutral-100 transition-colors">
166- <ArrowLeftIcon className="size-4" />
167- 返回
168- </Link>
169- </header>
170- <div className="flex min-h-0 flex-1">
171- <ParadigmSidebar
172- paradigms={paradigms ?? []}
173- selected={selected}
174- onSelect={name => updateUrl({ paradigm: name })}
175- />
176- <main className="flex min-w-0 flex-1 flex-col">
177- {selected ? (
178- <>
179- <FilterBar filters={filters} facets={runs?.facets ?? { frameworks: [], ccVersions: [] }} onChange={f => updateUrl(f)} />
180- {runs && runs.items.length > 0 && (
181- <div className="px-4 pt-3">
182- <OpgenProgress paradigm={selected} items={runs.items} />
183- </div>
184- )}
185- <div className="mt-2 flex items-center justify-between border-b border-neutral-200 bg-white px-4 py-1.5 text-xs text-neutral-500">
186- <span>
187- {selected}
188- {runs?.baselineSid && <span className="ml-2 text-emerald-600" title={`基准 ${runs.baselineSid}`}>◆ baseline</span>}
189- </span>
190- <span>
191- {loadingView ? '加载中…' : runs ? `共 ${runs.total} 条运行${runs.total > runs.items.length ? `(显示前 ${runs.items.length} 条)` : ''}` : ''}
192- </span>
193- </div>
194- <div className="min-h-0 flex-1 overflow-y-auto">
195- {error && <p className="p-4 text-sm text-red-600">{error}</p>}
196- {!error && runs && runs.items.length === 0 && (
197- <p className="p-8 text-center text-sm text-neutral-400">该筛选条件下无运行</p>
198- )}
199- {!error && runs && runs.items.length > 0 && (
200- <RunsTable items={runs.items} onImport={importRun} />
201- )}
202- </div>
203- </>
204- ) : (
205- <div className="flex flex-1 items-center justify-center">
206- {paradigms == null
207- ? <LakeProgressView />
208- : paradigms.length === 0
209- ? <p className="text-sm text-neutral-400">数据仓为空或不可达</p>
210- : <p className="text-sm text-neutral-400">选择左侧范式开始</p>}
211- </div>
212- )}
213- </main>
214- </div>
215- </div>
216- );
217-}
218- 
219-export default function Page() {
220- return (
221- <Suspense fallback={<div className="flex h-screen items-center justify-center text-sm text-neutral-400">加载中…</div>}>
222- <ViewerPage />
223- </Suspense>
224- );
225}14}
Rpackages/insight/src/app/cannbay_viewer/_components/FilterBar.tsx→packages/insight/src/app/lake_viewer/_components/FilterBar.tsx+0-0
文件重命名但无更改。
Rpackages/insight/src/app/cannbay_viewer/_components/LakeProgressView.tsx→packages/insight/src/app/lake_viewer/_components/LakeProgressView.tsx+2-2
@@ -9,7 +9,7 @@
9'use client';9'use client';
10 10 
11import { useEffect, useState } from 'react';11import { useEffect, useState } from 'react';
12-import { formatDuration } from '@/app/cannbay_viewer/_lib/format';12+import { formatDuration } from '@/app/lake_viewer/_lib/format';
13 13 
14interface LakeStatus {14interface LakeStatus {
15 phase: 'idle' | 'fetch' | 'clone' | 'list';15 phase: 'idle' | 'fetch' | 'clone' | 'list';
@@ -32,7 +32,7 @@ export function LakeProgressView() {
32 let cancelled = false;32 let cancelled = false;
33 const poll = async () => {33 const poll = async () => {
34 try {34 try {
35- const res = await fetch('/cannbay_viewer/api/lake-status');35+ const res = await fetch('/lake_viewer/api/lake-status');
36 const data = await res.json() as LakeStatus;36 const data = await res.json() as LakeStatus;
37 if (!cancelled) setStatus(data);37 if (!cancelled) setStatus(data);
38 } catch { /* 轮询失败保留上次状态 */ }38 } catch { /* 轮询失败保留上次状态 */ }
Rpackages/insight/src/app/cannbay_viewer/_components/NotesDialog.tsx→packages/insight/src/app/lake_viewer/_components/NotesDialog.tsx+4-4
@@ -9,7 +9,7 @@
9'use client';9'use client';
10 10 
11import { useEffect, useState } from 'react';11import { useEffect, useState } from 'react';
12-import { formatDateTime, formatDuration, formatScore, formatTokens } from '@/app/cannbay_viewer/_lib/format';12+import { formatDateTime, formatDuration, formatScore, formatTokens } from '@/app/lake_viewer/_lib/format';
13import type { RunItemView } from './RunsTable';13import type { RunItemView } from './RunsTable';
14 14 
15interface NoteEntry { ts: string; type: 'note'; text: string }15interface NoteEntry { ts: string; type: 'note'; text: string }
@@ -41,7 +41,7 @@ export function NotesDialog({ item, onClose }: { item: RunItemView; onClose: ()
41 41 
42 useEffect(() => {42 useEffect(() => {
43 let cancelled = false;43 let cancelled = false;
44- fetch(`/cannbay_viewer/api/runs/${encodeURIComponent(item.sid)}/notes?recording=${item.recording}`)44+ fetch(`/lake_viewer/api/runs/${encodeURIComponent(item.sid)}/notes?recording=${item.recording}`)
45 .then(r => r.json())45 .then(r => r.json())
46 .then((d: { records?: LakeRecord[]; submit?: SubmitInfo; error?: string }) => {46 .then((d: { records?: LakeRecord[]; submit?: SubmitInfo; error?: string }) => {
47 if (cancelled) return;47 if (cancelled) return;
@@ -63,7 +63,7 @@ export function NotesDialog({ item, onClose }: { item: RunItemView; onClose: ()
63 busy(true);63 busy(true);
64 setError(null);64 setError(null);
65 try {65 try {
66- const res = await fetch(`/cannbay_viewer/api/runs/${encodeURIComponent(item.sid)}/notes?recording=${item.recording}`, {66+ const res = await fetch(`/lake_viewer/api/runs/${encodeURIComponent(item.sid)}/notes?recording=${item.recording}`, {
67 method: 'POST',67 method: 'POST',
68 headers: { 'content-type': 'application/json' },68 headers: { 'content-type': 'application/json' },
69 body: JSON.stringify(body),69 body: JSON.stringify(body),
@@ -101,7 +101,7 @@ export function NotesDialog({ item, onClose }: { item: RunItemView; onClose: ()
101 setSavingOpGen(true);101 setSavingOpGen(true);
102 setError(null);102 setError(null);
103 try {103 try {
104- const res = await fetch(`/cannbay_viewer/api/runs/${encodeURIComponent(item.sid)}/notes?recording=${item.recording}`, {104+ const res = await fetch(`/lake_viewer/api/runs/${encodeURIComponent(item.sid)}/notes?recording=${item.recording}`, {
105 method: 'POST',105 method: 'POST',
106 headers: { 'content-type': 'application/json' },106 headers: { 'content-type': 'application/json' },
107 body: JSON.stringify({ kind: 'opgen', value }),107 body: JSON.stringify({ kind: 'opgen', value }),
Rpackages/insight/src/app/cannbay_viewer/_components/OpgenProgress.tsx→packages/insight/src/app/lake_viewer/_components/OpgenProgress.tsx+2-2
@@ -58,7 +58,7 @@ export function OpgenProgress({ paradigm, items }: { paradigm: string; items: Ru
58 58 
59 useEffect(() => {59 useEffect(() => {
60 let cancelled = false;60 let cancelled = false;
61- fetch(`/cannbay_viewer/api/paradigms/${encodeURIComponent(paradigm)}/target`)61+ fetch(`/lake_viewer/api/paradigms/${encodeURIComponent(paradigm)}/target`)
62 .then(r => r.json())62 .then(r => r.json())
63 .then((d: { target?: Target | null }) => { if (!cancelled) setLoaded({ paradigm, target: d.target ?? null }); })63 .then((d: { target?: Target | null }) => { if (!cancelled) setLoaded({ paradigm, target: d.target ?? null }); })
64 .catch(() => {64 .catch(() => {
@@ -85,7 +85,7 @@ export function OpgenProgress({ paradigm, items }: { paradigm: string; items: Ru
85 setSaving(true);85 setSaving(true);
86 setError(null);86 setError(null);
87 try {87 try {
88- const res = await fetch(`/cannbay_viewer/api/paradigms/${encodeURIComponent(paradigm)}/target`, {88+ const res = await fetch(`/lake_viewer/api/paradigms/${encodeURIComponent(paradigm)}/target`, {
89 method: 'POST',89 method: 'POST',
90 headers: { 'content-type': 'application/json' },90 headers: { 'content-type': 'application/json' },
91 body: JSON.stringify({ baselineMs: b, expectedMs: e }),91 body: JSON.stringify({ baselineMs: b, expectedMs: e }),
Rpackages/insight/src/app/cannbay_viewer/_components/ParadigmSidebar.tsx→packages/insight/src/app/lake_viewer/_components/ParadigmSidebar.tsx+0-0
文件重命名但无更改。
Rpackages/insight/src/app/cannbay_viewer/_components/RunsTable.tsx→packages/insight/src/app/lake_viewer/_components/RunsTable.tsx+1-1
@@ -9,7 +9,7 @@
9'use client';9'use client';
10 10 
11import { useMemo, useState } from 'react';11import { useMemo, useState } from 'react';
12-import { formatDateTime, formatDuration, formatScore, formatTokens, formatDelta } from '@/app/cannbay_viewer/_lib/format';12+import { formatDateTime, formatDuration, formatScore, formatTokens, formatDelta } from '@/app/lake_viewer/_lib/format';
13import { NotesDialog } from './NotesDialog';13import { NotesDialog } from './NotesDialog';
14import { toast } from 'sonner';14import { toast } from 'sonner';
15 15 
Rpackages/insight/src/app/cannbay_viewer/_lib/format.ts→packages/insight/src/app/lake_viewer/_lib/format.ts+0-0
文件重命名但无更改。
Rpackages/insight/src/app/cannbay_viewer/_lib/insight-data.ts→packages/insight/src/app/lake_viewer/_lib/insight-data.ts+4-4
@@ -7,11 +7,11 @@
7// See LICENSE in the root of the software repository for the full text of the License.7// See LICENSE in the root of the software repository for the full text of the License.
8 8 
9// viewer 进程内取数层:并入 insight 后不再 HTTP 自调用——会话指标直查 prisma、9// viewer 进程内取数层:并入 insight 后不再 HTTP 自调用——会话指标直查 prisma、
10-// 评分卡直用 scoring store/runner、导入直调 cannbay2 编排。对外保持旧 HTTP 层10+// 评分卡直用 scoring store/runner、导入直调 lake 编排。对外保持旧 HTTP 层
11// 的形状(Map<taskId> 键 + 失败 null 降级),runs.ts 聚合逻辑零改动。11// 的形状(Map<taskId> 键 + 失败 null 降级),runs.ts 聚合逻辑零改动。
12import { prisma } from '@/lib/db';12import { prisma } from '@/lib/db';
13import { runScorecard, scorecardStore, type Scorecard } from '@/lib/scoring';13import { runScorecard, scorecardStore, type Scorecard } from '@/lib/scoring';
14-import { importCannbay2Session } from '@/lib/cannbay2';14+import { importLakeSession } from '@/lib/lake';
15import { computeSessionActiveSplit } from '@/lib/active-time';15import { computeSessionActiveSplit } from '@/lib/active-time';
16import { isProxyVersion } from '@/lib/shared/session-format';16import { isProxyVersion } from '@/lib/shared/session-format';
17 17 
@@ -108,11 +108,11 @@ export async function getScorecard(taskId: string, framework?: string | null): P
108 };108 };
109}109}
110 110 
111-// 一键导入:cannbay2 导入编排(物化 + 写库 + x_cannbay 补列),大会话可达数分钟。111+// 一键导入:lake 导入编排(物化 + 写库 + x_cannbay 补列),大会话可达数分钟。
112// recording 定位仓内记录位:native = <sid>.native/(原生会话),capture = 裸 <sid>/112// recording 定位仓内记录位:native = <sid>.native/(原生会话),capture = 裸 <sid>/
113export async function importSession(sid: string, recording: 'capture' | 'native' = 'capture'): Promise<{ ok: boolean; error?: string }> {113export async function importSession(sid: string, recording: 'capture' | 'native' = 'capture'): Promise<{ ok: boolean; error?: string }> {
114 try {114 try {
115- await importCannbay2Session(sid, prisma, undefined, recording);115+ await importLakeSession(sid, prisma, undefined, recording);
116 return { ok: true };116 return { ok: true };
117 } catch (e) {117 } catch (e) {
118 return { ok: false, error: e instanceof Error ? e.message : String(e) };118 return { ok: false, error: e instanceof Error ? e.message : String(e) };
Rpackages/insight/src/app/cannbay_viewer/_lib/runs.ts→packages/insight/src/app/lake_viewer/_lib/runs.ts+10-6
@@ -11,9 +11,10 @@
11// 指标列空、开始时间退化为数据仓最后触碰 commit 时间(近似)。11// 指标列空、开始时间退化为数据仓最后触碰 commit 时间(近似)。
12// 过程内 TTL 缓存(镜像列表 60s / 本地会话 30s / 评分卡 300s)。12// 过程内 TTL 缓存(镜像列表 60s / 本地会话 30s / 评分卡 300s)。
13import { getActiveDurationMs, getScorecard, listInsightSessions, mapBounded, type InsightScorecard, type InsightSession } from './insight-data';13import { getActiveDurationMs, getScorecard, listInsightSessions, mapBounded, type InsightScorecard, type InsightSession } from './insight-data';
14-import { readLakeSessions, readParadigmDirs, resolveLakeRemote, revParseMain, ensureMirrorAsync } from './lake/mirror';14+import { readLakeSessions, readParadigmDirs, revParseMain, ensureMirrorAsync } from '@/lib/lake/mirror';
15-import { parseSubmitInfo } from './lake/notes';15+import { defaultLakeRemote } from '@/lib/lake/remote';
16-import type { LakeSession } from './lake/mirror';16+import { parseSubmitInfo } from '@/lib/lake/notes';
17+import type { LakeSession } from '@/lib/lake/mirror';
17import { normalizeScore } from './format';18import { normalizeScore } from './format';
18import { agentFrameworkOf } from '@/lib/shared/session-format';19import { agentFrameworkOf } from '@/lib/shared/session-format';
19 20 
@@ -110,15 +111,18 @@ interface LakeListing {
110}111}
111 112 
112async function buildLakeListing(): Promise<LakeListing> {113async function buildLakeListing(): Promise<LakeListing> {
113- const remote = resolveLakeRemote();114+ const remote = defaultLakeRemote();
114 const dir = await ensureMirrorAsync(remote);115 const dir = await ensureMirrorAsync(remote);
115 const head = revParseMain(dir, remote);116 const head = revParseMain(dir, remote);
116 const sessions = head ? await readLakeSessions(dir, remote) : [];117 const sessions = head ? await readLakeSessions(dir, remote) : [];
117- const dirs = new Set(readParadigmDirs(dir, remote));118+ // 根级会话目录(缺省上传位 <sid>/,paradigm='')不是范式——从侧栏目录
119+ // 清单剔除(否则每个根级会话都会以 sid 名义混进范式注册表)
120+ const rootFolders = new Set(sessions.filter(s => !s.paradigm).map(s => s.sid + (s.recording === 'native' ? '.native' : '')));
121+ const dirs = new Set(readParadigmDirs(dir, remote).filter(d => !rootFolders.has(d)));
118 const byParadigm = new Map<string, ParadigmSummary>();122 const byParadigm = new Map<string, ParadigmSummary>();
119 const baselineByParadigm = new Map<string, string>();123 const baselineByParadigm = new Map<string, string>();
120 for (const s of sessions) {124 for (const s of sessions) {
121- if (!s.paradigm || s.paradigm === 'sessions' || !dirs.has(s.paradigm)) continue;125+ if (!s.paradigm || !dirs.has(s.paradigm)) continue;
122 if (s.isBaseline) {126 if (s.isBaseline) {
123 if (!baselineByParadigm.has(s.paradigm)) baselineByParadigm.set(s.paradigm, s.sid);127 if (!baselineByParadigm.has(s.paradigm)) baselineByParadigm.set(s.paradigm, s.sid);
124 continue;128 continue;
Rpackages/insight/src/app/cannbay_viewer/api/lake-status/route.ts→packages/insight/src/app/lake_viewer/api/lake-status/route.ts+1-1
@@ -7,7 +7,7 @@
7// See LICENSE in the root of the software repository for the full text of the License.7// See LICENSE in the root of the software repository for the full text of the License.
8 8 
9import { NextResponse } from 'next/server';9import { NextResponse } from 'next/server';
10-import { lakeProgress } from '@/app/cannbay_viewer/_lib/lake/mirror';10+import { lakeProgress } from '@/lib/lake/mirror';
11 11 
12export async function GET() {12export async function GET() {
13 return NextResponse.json(lakeProgress());13 return NextResponse.json(lakeProgress());
Rpackages/insight/src/app/cannbay_viewer/api/paradigms/[name]/target/route.ts→packages/insight/src/app/lake_viewer/api/paradigms/[name]/target/route.ts+1-1
@@ -7,7 +7,7 @@
7// See LICENSE in the root of the software repository for the full text of the License.7// See LICENSE in the root of the software repository for the full text of the License.
8 8 
9import { NextResponse } from 'next/server';9import { NextResponse } from 'next/server';
10-import { readParadigmTarget, setParadigmTarget, NotesConflictError } from '@/app/cannbay_viewer/_lib/lake/notes';10+import { readParadigmTarget, setParadigmTarget, NotesConflictError } from '@/lib/lake/notes';
11 11 
12export async function GET(_req: Request, ctx: { params: Promise<{ name: string }> }) {12export async function GET(_req: Request, ctx: { params: Promise<{ name: string }> }) {
13 const { name } = await ctx.params;13 const { name } = await ctx.params;
Rpackages/insight/src/app/cannbay_viewer/api/paradigms/route.ts→packages/insight/src/app/lake_viewer/api/paradigms/route.ts+2-2
@@ -7,8 +7,8 @@
7// See LICENSE in the root of the software repository for the full text of the License.7// See LICENSE in the root of the software repository for the full text of the License.
8 8 
9import { NextResponse } from 'next/server';9import { NextResponse } from 'next/server';
10-import { listParadigms } from '@/app/cannbay_viewer/_lib/runs';10+import { listParadigms } from '@/app/lake_viewer/_lib/runs';
11-import { ensureMirrorAsync, markLakePhase } from '@/app/cannbay_viewer/_lib/lake/mirror';11+import { ensureMirrorAsync, markLakePhase } from '@/lib/lake/mirror';
12 12 
13export async function GET() {13export async function GET() {
14 try {14 try {
Rpackages/insight/src/app/cannbay_viewer/api/runs/[sid]/notes/route.ts→packages/insight/src/app/lake_viewer/api/runs/[sid]/notes/route.ts+4-4
@@ -7,10 +7,10 @@
7// See LICENSE in the root of the software repository for the full text of the License.7// See LICENSE in the root of the software repository for the full text of the License.
8 8 
9import { NextResponse } from 'next/server';9import { NextResponse } from 'next/server';
10-import { getLakeListing, invalidateCaches } from '@/app/cannbay_viewer/_lib/runs';10+import { getLakeListing, invalidateCaches } from '@/app/lake_viewer/_lib/runs';
11-import { appendNote, appendScorecard, editNote, readRecords, readSubmitInfo, setOpGen, NotesConflictError } from '@/app/cannbay_viewer/_lib/lake/notes';11+import { appendNote, appendScorecard, editNote, readRecords, readSubmitInfo, setOpGen, NotesConflictError } from '@/lib/lake/notes';
12-import type { SessionLoc, OpGenValue } from '@/app/cannbay_viewer/_lib/lake/notes';12+import type { SessionLoc, OpGenValue } from '@/lib/lake/notes';
13-import { getScorecard } from '@/app/cannbay_viewer/_lib/insight-data';13+import { getScorecard } from '@/app/lake_viewer/_lib/insight-data';
14 14 
15// recording 消歧:同 sid 双记录(捕获 + 原生)时按参数定位;缺省优先捕获位15// recording 消歧:同 sid 双记录(捕获 + 原生)时按参数定位;缺省优先捕获位
16// (存量兼容——旧客户端/裸目录会话无 recording 概念)16// (存量兼容——旧客户端/裸目录会话无 recording 概念)
Rpackages/insight/src/app/cannbay_viewer/api/runs/import/route.ts→packages/insight/src/app/lake_viewer/api/runs/import/route.ts+2-2文件内容审核中,请稍后刷新重试
Rpackages/insight/src/app/cannbay_viewer/api/runs/route.ts→packages/insight/src/app/lake_viewer/api/runs/route.ts+2-2
@@ -7,8 +7,8 @@
7// See LICENSE in the root of the software repository for the full text of the License.7// See LICENSE in the root of the software repository for the full text of the License.
8 8 
9import { NextRequest, NextResponse } from 'next/server';9import { NextRequest, NextResponse } from 'next/server';
10-import { listRuns } from '@/app/cannbay_viewer/_lib/runs';10+import { listRuns } from '@/app/lake_viewer/_lib/runs';
11-import { ensureMirrorAsync } from '@/app/cannbay_viewer/_lib/lake/mirror';11+import { ensureMirrorAsync } from '@/lib/lake/mirror';
12 12 
13function csv(p: string | null): string[] | undefined {13function csv(p: string | null): string[] | undefined {
14 if (!p) return undefined;14 if (!p) return undefined;
Apackages/insight/src/app/lake_viewer/page.tsx+225-0文件内容审核中,请稍后刷新重试
@@ -163,7 +163,7 @@ export default async function Home({
163 </div>163 </div>
164 <div className="flex items-center gap-3">164 <div className="flex items-center gap-3">
165 <LocalFileImport />165 <LocalFileImport />
166- <a href="/cannbay_viewer" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border text-sm font-medium hover:bg-accent/30 transition-colors">166+ <a href="/lake_viewer" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border text-sm font-medium hover:bg-accent/30 transition-colors">
167 <EyeIcon className="size-4 text-cyan-500" />167 <EyeIcon className="size-4 text-cyan-500" />
168 {BAY_VIEWER_NAME}168 {BAY_VIEWER_NAME}
169 </a>169 </a>
@@ -24,7 +24,7 @@ import type { TurnHighlight } from "@/lib/shared/highlight"
24import { TurnTimeline } from "@/components/observe/TurnTimeline"24import { TurnTimeline } from "@/components/observe/TurnTimeline"
25import { TurnDetail } from "@/components/observe/TurnDetail"25import { TurnDetail } from "@/components/observe/TurnDetail"
26import { buildSubagentNumbers } from "@/lib/subagent-numbering"26import { buildSubagentNumbers } from "@/lib/subagent-numbering"
27-import { UploadToCannbayDialog } from "@/components/UploadToCannbayDialog"27+import { UploadToLakeDialog } from "@/components/UploadToLakeDialog"
28import { WireRounds } from "@/components/observe/WireRounds"28import { WireRounds } from "@/components/observe/WireRounds"
29import { TurnContextPanel } from "@/components/observe/TurnContextPanel"29import { TurnContextPanel } from "@/components/observe/TurnContextPanel"
30import { SkillDetail } from "@/components/observe/SkillDetail"30import { SkillDetail } from "@/components/observe/SkillDetail"
@@ -568,7 +568,7 @@ export default function SessionDetailPage({
568 const [exportingMd, setExportingMd] = useState(false)568 const [exportingMd, setExportingMd] = useState(false)
569 const [exportingHtml, setExportingHtml] = useState(false)569 const [exportingHtml, setExportingHtml] = useState(false)
570 // 数据仓上传弹窗(共享组件,与主页面上传同款):结果经 toast 呈现570 // 数据仓上传弹窗(共享组件,与主页面上传同款):结果经 toast 呈现
571- const [cannbayUploadOpen, setCannbayUploadOpen] = useState(false)571+ const [lakeUploadOpen, setLakeUploadOpen] = useState(false)
572 const [refreshing, setRefreshing] = useState(false)572 const [refreshing, setRefreshing] = useState(false)
573 const refreshingRef = useRef(false)573 const refreshingRef = useRef(false)
574 const lastMaxTimeUpdatedRef = useRef<number>(-1)574 const lastMaxTimeUpdatedRef = useRef<number>(-1)
@@ -1930,7 +1930,7 @@ export default function SessionDetailPage({
1930 variant="ghost"1930 variant="ghost"
1931 size="sm"1931 size="sm"
1932 className="gap-1 text-muted-foreground"1932 className="gap-1 text-muted-foreground"
1933- onClick={() => setCannbayUploadOpen(true)}1933+ onClick={() => setLakeUploadOpen(true)}
1934 title={`Upload to ${BAY_NAME}`}1934 title={`Upload to ${BAY_NAME}`}
1935 >1935 >
1936 <UploadIcon className="size-4" />1936 <UploadIcon className="size-4" />
@@ -1976,9 +1976,9 @@ export default function SessionDetailPage({
1976 </div>1976 </div>
1977 ))}1977 ))}
1978 1978 
1979- <UploadToCannbayDialog1979+ <UploadToLakeDialog
1980- open={cannbayUploadOpen}1980+ open={lakeUploadOpen}
1981- onOpenChange={setCannbayUploadOpen}1981+ onOpenChange={setLakeUploadOpen}
1982 target={session ? {1982 target={session ? {
1983 taskId: session.taskId,1983 taskId: session.taskId,
1984 query: session.query ?? session.label,1984 query: session.query ?? session.label,
@@ -17,19 +17,16 @@ import { BRAND_NAME, BAY_NAME } from "@/lib/branding"
17import { VERSION_DISPLAY } from "@/lib/version"17import { VERSION_DISPLAY } from "@/lib/version"
18import { AIProviderConfigPanel } from "@/components/observe/AIProviderConfigPanel"18import { AIProviderConfigPanel } from "@/components/observe/AIProviderConfigPanel"
19import {19import {
20- loadCannbayConfig,20+ loadLakeConfig,
21- saveCannbayConfig,21+ clearLakeConfig,
22- clearCannbayConfig,
23- cannbayRemoteInput,
24 planCredentialAction,22 planCredentialAction,
25- DEFAULT_CANNBAY_ADDRESS,23+ type LakeConfig,
26- type CannbayConfig,24+ type LakeAuthMode,
27- type CannbayAuthMode,25+} from "@/lib/lake-config"
28-} from "@/lib/cannbay-config"
29 26 
30export default function SettingsPage() {27export default function SettingsPage() {
31 const [address, setAddress] = useState("")28 const [address, setAddress] = useState("")
32- const [authMode, setAuthMode] = useState<CannbayAuthMode>("none")29+ const [authMode, setAuthMode] = useState<LakeAuthMode>("none")
33 const [account, setAccount] = useState("")30 const [account, setAccount] = useState("")
34 const [password, setPassword] = useState("")31 const [password, setPassword] = useState("")
35 const [token, setToken] = useState("")32 const [token, setToken] = useState("")
@@ -52,36 +49,55 @@ export default function SettingsPage() {
52 const [branchError, setBranchError] = useState<string | null>(null)49 const [branchError, setBranchError] = useState<string | null>(null)
53 50 
54 useEffect(() => {51 useEffect(() => {
55- // 渲染期零读 localStorage(hydration 契约,同 v1.84 AIProviderConfigPanel);52+ // 渲染期零读 localStorage(hydration 契约,同 v1.84 AIProviderConfigPanel)。
56- // microtask 填充:挂载后首帧补值,不与 SSR 首渲染竞争53+ // 单源架构:服务端 lake-config.json 为准(全机生效);未配置时用 legacy
57- queueMicrotask(() => {54+ // localStorage 值预填表单(存量用户保存一次即完成迁移)。凭据形态字段
58- const saved = loadCannbayConfig()55+ // (authMode/账号/令牌)不落服务端配置文件,从 legacy 预填或空
59- if (saved) {56+ void (async () => {
60- setAddress(saved.address)57+ const legacy = loadLakeConfig()
61- setAuthMode(saved.authMode)58+ try {
62- setAccount(saved.account)59+ const res = await fetch("/api/settings/lake-config")
63- setPassword(saved.password)60+ const data = await res.json() as { configured?: boolean; address?: string; branch?: string; directory?: string }
64- setToken(saved.token)61+ if (data.configured && data.address) {
65- setBranch(saved.branch)62+ setAddress(data.address)
66- setDirectory(saved.directory)63+ setBranch(data.branch?.trim() || "main")
67- setSavedFlag(true)64+ setDirectory(data.directory ?? "")
68- } else {65+ setSavedFlag(true)
69- setAddress(DEFAULT_CANNBAY_ADDRESS)66+ } else if (legacy) {
70- setBranch("main")67+ setAddress(legacy.address)
68+ setBranch(legacy.branch)
69+ setDirectory(legacy.directory)
70+ } else {
71+ setBranch("main")
72+ }
73+ } catch {
74+ if (legacy) {
75+ setAddress(legacy.address)
76+ setBranch(legacy.branch)
77+ setDirectory(legacy.directory)
78+ } else {
79+ setBranch("main")
80+ }
81+ }
82+ if (legacy) {
83+ setAuthMode(legacy.authMode)
84+ setAccount(legacy.account)
85+ setPassword(legacy.password)
86+ setToken(legacy.token)
71 }87 }
72 setHydrated(true)88 setHydrated(true)
73- })89+ })()
74 }, [])90 }, [])
75 91 
76- // 服务端凭据状态(cannbay-auth.json):按当前地址展示已配置的令牌脱敏信息92+ // 服务端凭据状态(lake-auth.json):按当前地址展示已配置的令牌脱敏信息
77 useEffect(() => {93 useEffect(() => {
78 if (!hydrated) return94 if (!hydrated) return
79 let active = true95 let active = true
80- fetch("/api/settings/cannbay-auth")96+ fetch("/api/settings/lake-auth")
81 .then(r => r.json())97 .then(r => r.json())
82 .then((data: { remotes?: Array<{ url: string; username: string; tokenMasked: string; verifiedAt?: string }> }) => {98 .then((data: { remotes?: Array<{ url: string; username: string; tokenMasked: string; verifiedAt?: string }> }) => {
83 if (!active) return99 if (!active) return
84- const key = (address.trim() || DEFAULT_CANNBAY_ADDRESS).replace(/\/+$/, "")100+ const key = address.trim().replace(/\/+$/, "")
85 const hit = data.remotes?.find(r => r.url === key || r.url === `${key}.git` || r.url.replace(/\.git$/, "") === key.replace(/\.git$/, ""))101 const hit = data.remotes?.find(r => r.url === key || r.url === `${key}.git` || r.url.replace(/\.git$/, "") === key.replace(/\.git$/, ""))
86 setAuthStatus(hit ?? null)102 setAuthStatus(hit ?? null)
87 })103 })
@@ -89,7 +105,7 @@ export default function SettingsPage() {
89 return () => { active = false }105 return () => { active = false }
90 }, [hydrated, address])106 }, [hydrated, address])
91 107 
92- const config: CannbayConfig = {108+ const config: LakeConfig = {
93 address: address.trim(),109 address: address.trim(),
94 authMode,110 authMode,
95 account,111 account,
@@ -105,18 +121,25 @@ export default function SettingsPage() {
105 setAuthHint(null)121 setAuthHint(null)
106 }122 }
107 123 
108- // 保存:UI 偏好(地址/认证方式/分支/目录)落 localStorage;凭据(用户名+124+ // 保存(单源架构):远端偏好(地址/分支/目录)落服务端 lake-config.json
109- // 令牌/密码)落服务端 cannbay-auth.json(与 cpx 同机共享)。凭据操作走125+ // ——全机生效(导入/上传/Viewer/CLI 共读同一份);legacy localStorage 同步
110- // planCredentialAction 决策:匿名模式清除该地址凭据;token/password 模式126+ // 清除(防旧值在服务端配置清空后复活)。凭据(用户名+令牌/密码)落服务端
111- // 输入留空 = 保留现有(不落入删除分支)127+ // lake-auth.json(与 cpx 同机共享)。凭据操作走 planCredentialAction 决策:
128+ // 匿名模式清除该地址凭据;token/password 模式输入留空 = 保留现有
112 async function handleSave() {129 async function handleSave() {
113- saveCannbayConfig(config)
114 setSavedFlag(true)130 setSavedFlag(true)
115- const remoteUrl = (address.trim() || DEFAULT_CANNBAY_ADDRESS).replace(/\/+$/, "")131+ const remoteUrl = address.trim().replace(/\/+$/, "")
116 try {132 try {
133+ const cfgRes = await fetch("/api/settings/lake-config", {
134+ method: "PUT",
135+ headers: { "Content-Type": "application/json" },
136+ body: JSON.stringify({ address: remoteUrl, branch: branch.trim() || "main", directory: directory.trim() }),
137+ })
138+ if (!cfgRes.ok) throw new Error(String((await cfgRes.json()).error ?? "远端偏好保存失败"))
139+ clearLakeConfig()
117 const action = planCredentialAction(config)140 const action = planCredentialAction(config)
118 if (action === "save-token") {141 if (action === "save-token") {
119- const res = await fetch("/api/settings/cannbay-auth", {142+ const res = await fetch("/api/settings/lake-auth", {
120 method: "PUT",143 method: "PUT",
121 headers: { "Content-Type": "application/json" },144 headers: { "Content-Type": "application/json" },
122 body: JSON.stringify({ url: remoteUrl, username: account.trim() || "oauth2", token: token.trim() }),145 body: JSON.stringify({ url: remoteUrl, username: account.trim() || "oauth2", token: token.trim() }),
@@ -126,7 +149,7 @@ export default function SettingsPage() {
126 setAuthStatus({ username: account.trim() || "oauth2", tokenMasked: String(data.tokenMasked ?? "****"), verifiedAt: data.verifiedAt ?? undefined })149 setAuthStatus({ username: account.trim() || "oauth2", tokenMasked: String(data.tokenMasked ?? "****"), verifiedAt: data.verifiedAt ?? undefined })
127 setAuthHint(data.reachable === false ? "已保存,但地址探测未通过(私有仓/网络问题可忽略,令牌有效性由首次上传确认)" : "凭据已保存到本机服务端(cpx 与 insight 共用)")150 setAuthHint(data.reachable === false ? "已保存,但地址探测未通过(私有仓/网络问题可忽略,令牌有效性由首次上传确认)" : "凭据已保存到本机服务端(cpx 与 insight 共用)")
128 } else if (action === "save-password") {151 } else if (action === "save-password") {
129- const res = await fetch("/api/settings/cannbay-auth", {152+ const res = await fetch("/api/settings/lake-auth", {
130 method: "PUT",153 method: "PUT",
131 headers: { "Content-Type": "application/json" },154 headers: { "Content-Type": "application/json" },
132 body: JSON.stringify({ url: remoteUrl, username: account.trim(), token: password.trim() }),155 body: JSON.stringify({ url: remoteUrl, username: account.trim(), token: password.trim() }),
@@ -136,7 +159,7 @@ export default function SettingsPage() {
136 setAuthStatus({ username: account.trim(), tokenMasked: String(data.tokenMasked ?? "****"), verifiedAt: data.verifiedAt ?? undefined })159 setAuthStatus({ username: account.trim(), tokenMasked: String(data.tokenMasked ?? "****"), verifiedAt: data.verifiedAt ?? undefined })
137 setAuthHint(data.reachable === false ? "已保存,但地址探测未通过(私有仓/网络问题可忽略,令牌有效性由首次上传确认)" : "凭据已保存到本机服务端(cpx 与 insight 共用)")160 setAuthHint(data.reachable === false ? "已保存,但地址探测未通过(私有仓/网络问题可忽略,令牌有效性由首次上传确认)" : "凭据已保存到本机服务端(cpx 与 insight 共用)")
138 } else if (action === "clear") {161 } else if (action === "clear") {
139- await fetch("/api/settings/cannbay-auth", {162+ await fetch("/api/settings/lake-auth", {
140 method: "DELETE",163 method: "DELETE",
141 headers: { "Content-Type": "application/json" },164 headers: { "Content-Type": "application/json" },
142 body: JSON.stringify({ url: remoteUrl }),165 body: JSON.stringify({ url: remoteUrl }),
@@ -152,14 +175,15 @@ export default function SettingsPage() {
152 }175 }
153 176 
154 async function handleClear() {177 async function handleClear() {
155- clearCannbayConfig()178+ clearLakeConfig()
156- const remoteUrl = (address.trim() || DEFAULT_CANNBAY_ADDRESS).replace(/\/+$/, "")179+ const remoteUrl = address.trim().replace(/\/+$/, "")
157- fetch("/api/settings/cannbay-auth", {180+ fetch("/api/settings/lake-config", { method: "DELETE" }).catch(() => { /* best-effort */ })
181+ fetch("/api/settings/lake-auth", {
158 method: "DELETE",182 method: "DELETE",
159 headers: { "Content-Type": "application/json" },183 headers: { "Content-Type": "application/json" },
160 body: JSON.stringify({ url: remoteUrl }),184 body: JSON.stringify({ url: remoteUrl }),
161 }).catch(() => { /* best-effort */ })185 }).catch(() => { /* best-effort */ })
162- setAddress(DEFAULT_CANNBAY_ADDRESS)186+ setAddress("")
163 setAuthMode("none")187 setAuthMode("none")
164 setAccount("")188 setAccount("")
165 setPassword("")189 setPassword("")
@@ -176,11 +200,23 @@ export default function SettingsPage() {
176 setTesting(true)200 setTesting(true)
177 setTestResult(null)201 setTestResult(null)
178 try {202 try {
179- // 测试的是用户正在输入的凭据(includeCreds);表单为空则测服务端已存凭据203+ // 测试的是用户正在输入的值(编辑态探测——含表单凭据即时验证;
180- const res = await fetch("/api/ingest/cannbay2", {204+ // 表单凭据空则测服务端已存凭据)
205+ const res = await fetch("/api/ingest/lake", {
181 method: "POST",206 method: "POST",
182 headers: { "Content-Type": "application/json" },207 headers: { "Content-Type": "application/json" },
183- body: JSON.stringify({ action: "test", ...cannbayRemoteInput(config, { includeCreds: true }) }),208+ body: JSON.stringify({
209+ action: "test",
210+ config: {
211+ address: address.trim(),
212+ authMode,
213+ account: account.trim(),
214+ password,
215+ token,
216+ branch: branch.trim() || "main",
217+ directory: directory.trim(),
218+ },
219+ }),
184 })220 })
185 const data = await res.json()221 const data = await res.json()
186 setTestResult({222 setTestResult({
@@ -198,10 +234,14 @@ export default function SettingsPage() {
198 setBrowseLoading(true)234 setBrowseLoading(true)
199 setBrowseError(null)235 setBrowseError(null)
200 try {236 try {
201- const res = await fetch("/api/ingest/cannbay2", {237+ const res = await fetch("/api/ingest/lake", {
202 method: "POST",238 method: "POST",
203 headers: { "Content-Type": "application/json" },239 headers: { "Content-Type": "application/json" },
204- body: JSON.stringify({ action: "dirs", prefix, ...cannbayRemoteInput(config) }),240+ body: JSON.stringify({
241+ action: "dirs",
242+ prefix,
243+ config: { address: address.trim(), branch: branch.trim() || "main", directory: directory.trim() },
244+ }),
205 })245 })
206 const data = await res.json()246 const data = await res.json()
207 if (!res.ok) throw new Error(String(data.error ?? "获取目录失败"))247 if (!res.ok) throw new Error(String(data.error ?? "获取目录失败"))
@@ -231,10 +271,13 @@ export default function SettingsPage() {
231 setBranchLoading(true)271 setBranchLoading(true)
232 setBranchError(null)272 setBranchError(null)
233 try {273 try {
234- const res = await fetch("/api/ingest/cannbay2", {274+ const res = await fetch("/api/ingest/lake", {
235 method: "POST",275 method: "POST",
236 headers: { "Content-Type": "application/json" },276 headers: { "Content-Type": "application/json" },
237- body: JSON.stringify({ action: "branches", ...cannbayRemoteInput(config) }),277+ body: JSON.stringify({
278+ action: "branches",
279+ config: { address: address.trim(), branch: branch.trim() || "main", directory: directory.trim() },
280+ }),
238 })281 })
239 const data = await res.json()282 const data = await res.json()
240 if (!res.ok) throw new Error(String(data.error ?? "获取分支失败"))283 if (!res.ok) throw new Error(String(data.error ?? "获取分支失败"))
@@ -296,7 +339,7 @@ export default function SettingsPage() {
296 if (branchOpen) closeBranch()339 if (branchOpen) closeBranch()
297 }}340 }}
298 className="h-7 text-xs flex-1"341 className="h-7 text-xs flex-1"
299- placeholder={DEFAULT_CANNBAY_ADDRESS}342+ placeholder="https://example.com/your-lake.git"
300 />343 />
301 </div>344 </div>
302 <div className="flex items-center gap-2">345 <div className="flex items-center gap-2">
@@ -375,7 +418,7 @@ export default function SettingsPage() {
375 )}418 )}
376 <div className="flex items-center gap-2">419 <div className="flex items-center gap-2">
377 <span className="text-xs text-muted-foreground w-16 shrink-0">目录</span>420 <span className="text-xs text-muted-foreground w-16 shrink-0">目录</span>
378- <Input value={directory} onChange={(e) => { setDirectory(e.target.value); dirty() }} className="h-7 text-xs" placeholder="sessions(默认)" />421+ <Input value={directory} onChange={(e) => { setDirectory(e.target.value); dirty() }} className="h-7 text-xs" placeholder="留空 = 根目录" />
379 <Button size="sm" variant="outline" className="h-7 text-xs shrink-0" onClick={handleBrowse} disabled={!hydrated}>浏览</Button>422 <Button size="sm" variant="outline" className="h-7 text-xs shrink-0" onClick={handleBrowse} disabled={!hydrated}>浏览</Button>
380 </div>423 </div>
381 {browseOpen && (424 {browseOpen && (
@@ -401,16 +444,19 @@ export default function SettingsPage() {
401 <Button444 <Button
402 size="sm"445 size="sm"
403 className="h-6 px-2 text-xs"446 className="h-6 px-2 text-xs"
404- disabled={!browsePrefix}
405 onClick={() => { setDirectory(browsePrefix); dirty(); closeBrowse() }}447 onClick={() => { setDirectory(browsePrefix); dirty(); closeBrowse() }}
406 >448 >
407- 选定 /{browsePrefix}449+ 选定 /{browsePrefix || ""}
408 </Button>450 </Button>
409- {!browsePrefix && <span className="text-xs text-muted-foreground">根目录不可作为会话目录(留空 = 默认 sessions/)</span>}451+ {browsePrefix && (
452+ <Button size="sm" variant="ghost" className="h-6 px-2 text-xs" onClick={() => { setDirectory(""); dirty(); closeBrowse() }}>
453+ 选定根目录
454+ </Button>
455+ )}
410 </div>456 </div>
411 </div>457 </div>
412 )}458 )}
413- <p className="text-xs text-muted-foreground pl-[72px]">目录替换默认 <code>sessions/</code> 前缀,列表 / 导入 / 上传范围同步生效,可点「浏览」从仓库中选择。</p>459+ <p className="text-xs text-muted-foreground pl-[72px]">上传缺省目录(无范式目录时的落盘位):留空 = 仓库根(<code>&lt;sid&gt;/</code> 根级),可点「浏览」从仓库中选择。</p>
414 </div>460 </div>
415 <div className="flex items-center gap-2">461 <div className="flex items-center gap-2">
416 <Button size="sm" className="text-xs" onClick={handleSave} disabled={!hydrated || !address.trim()}>462 <Button size="sm" className="text-xs" onClick={handleSave} disabled={!hydrated || !address.trim()}>
@@ -421,7 +467,7 @@ export default function SettingsPage() {
421 </Button>467 </Button>
422 {savedFlag && <span className="text-xs text-green-600">✓ 已保存</span>}468 {savedFlag && <span className="text-xs text-green-600">✓ 已保存</span>}
423 </div>469 </div>
424- <p className="text-xs text-muted-foreground">凭据保存在本机服务端(~/.cannbot-insight/cannbay-auth.json,权限 0600),cpx 与 insight 上传/下载共用同一份;地址/分支/目录等偏好存浏览器本地。数据仓默认需凭据推送(需仓主将你的账号加为协作者)。</p>470+ <p className="text-xs text-muted-foreground">凭据保存在本机服务端(~/.cannbot-insight/lake-auth.json,权限 0600),cpx 与 insight 上传/下载共用同一份;地址/分支/目录偏好同时存浏览器本地与服务端(~/.cannbot-insight/lake-config.json)——viewer 与跨浏览器共享同一配置。数据仓默认需凭据推送(需仓主将你的账号加为协作者)。</p>
425 </CardContent>471 </CardContent>
426 </Card>472 </Card>
427 </main>473 </main>
Mpackages/insight/src/cli/client.ts+2-2文件内容审核中,请稍后刷新重试
@@ -19,8 +19,8 @@ import { spawnSync } from 'node:child_process';
19import {19import {
20 authFilePath, clearStoredCredentials, getStoredCredentials,20 authFilePath, clearStoredCredentials, getStoredCredentials,
21 listStoredRemotes, maskToken, setStoredCredentials,21 listStoredRemotes, maskToken, setStoredCredentials,
22-} from '@/lib/cannbay2/auth';22+} from '@/lib/lake/auth';
23-import { DEFAULT_CANNBAY_ADDRESS } from '@/lib/cannbay-config';23+import { readServerLakeConfig } from '@/lib/lake/server-config';
24import { formatSuccess, formatWarning } from '../utils/colors';24import { formatSuccess, formatWarning } from '../utils/colors';
25 25 
26export interface AuthDeps {26export interface AuthDeps {
@@ -157,8 +157,8 @@ export async function runAuthAction(opts: AuthOptions, deps: AuthDeps = {}): Pro
157 }));157 }));
158 const muted = deps.questionMuted ?? mutedQuestion;158 const muted = deps.questionMuted ?? mutedQuestion;
159 159 
160- let url = (await question(`远端地址(回车 = ${DEFAULT_CANNBAY_ADDRESS}): `)).trim();160+ let url = (await question('远端地址(回车 = 使用服务端配置的当前数据仓): ')).trim();
161- if (!url) url = DEFAULT_CANNBAY_ADDRESS;161+ if (!url) url = readServerLakeConfig()?.address ?? '';
162 const existing = getStoredCredentials(url);162 const existing = getStoredCredentials(url);
163 if (existing) log(`注意:该远端已有配置(凭据 ${maskToken(existing.password)}),本次保存将覆盖`);163 if (existing) log(`注意:该远端已有配置(凭据 ${maskToken(existing.password)}),本次保存将覆盖`);
164 let username = (await question(`用户名(回车 = ${defaultUsername() || '必填'}): `)).trim();164 let username = (await question(`用户名(回车 = ${defaultUsername() || '必填'}): `)).trim();
@@ -55,7 +55,7 @@ async function inputPrompt(label: string, defaultVal: string): Promise<string> {
55}55}
56 56 
57// 提交人必填:回车跳过(默认值为空)时重复提示 —— 空提交人的 commit author57// 提交人必填:回车跳过(默认值为空)时重复提示 —— 空提交人的 commit author
58-// 会顶替为机器身份 cannbot-insight(cannbay2/index.ts 网关同样强制拦截)58+// 会顶替为机器身份 cannbot-insight(lake/index.ts 网关同样强制拦截)
59async function submitterPrompt(defaultVal: string): Promise<string> {59async function submitterPrompt(defaultVal: string): Promise<string> {
60 for (;;) {60 for (;;) {
61 const v = await inputPrompt('提交人(必填)', defaultVal);61 const v = await inputPrompt('提交人(必填)', defaultVal);
Rpackages/insight/src/components/CannbayTokenDialog.tsx→packages/insight/src/components/LakeTokenDialog.tsx+71-20
@@ -9,13 +9,13 @@
9 9 
10// 数据仓推送凭据引导配置弹窗:未配置(默认仓上传弹窗打开即检)或凭据类失败10// 数据仓推送凭据引导配置弹窗:未配置(默认仓上传弹窗打开即检)或凭据类失败
11// (令牌无效 / 无写权限)时弹出,免绕道设置页。保存走11// (令牌无效 / 无写权限)时弹出,免绕道设置页。保存走
12-// PUT /api/settings/cannbay-auth(服务端 cannbay-auth.json,一次配置 cpx /12+// PUT /api/settings/lake-auth(服务端 lake-auth.json,一次配置 cpx /
13// insight 同机共享)。reason 决定文案语境;保存成功给出脱敏回执并自动收起。13// insight 同机共享)。reason 决定文案语境;保存成功给出脱敏回执并自动收起。
14// 动效:入场 fade + zoom(96%) + 上滑(expo-out 缓动)、图标弹性入场(带14// 动效:入场 fade + zoom(96%) + 上滑(expo-out 缓动)、图标弹性入场(带
15// prefers-reduced-motion 降级)、成功态弹性弹出。15// prefers-reduced-motion 降级)、成功态弹性弹出。
16 16 
17import { useEffect, useState } from 'react'17import { useEffect, useState } from 'react'
18-import { loadCannbayConfig } from '@/lib/cannbay-config'18+ 
19import { Button } from '@/components/ui/button'19import { Button } from '@/components/ui/button'
20import { Input } from '@/components/ui/input'20import { Input } from '@/components/ui/input'
21import {21import {
@@ -31,22 +31,23 @@ import {
31 EyeIcon,31 EyeIcon,
32 EyeOffIcon,32 EyeOffIcon,
33 KeyRoundIcon,33 KeyRoundIcon,
34+ LinkIcon,
34 Loader2Icon,35 Loader2Icon,
35 ShieldAlertIcon,36 ShieldAlertIcon,
36} from 'lucide-react'37} from 'lucide-react'
37import {38import {
38 credentialPromptCopy,39 credentialPromptCopy,
39 findStoredAuthEntry,40 findStoredAuthEntry,
40- resolveRemoteAddress,41+ serverLakeConfigStatus,
41- type CannbayTokenReason,42+ type LakeTokenReason,
42 type StoredRemoteInfo,43 type StoredRemoteInfo,
43-} from '@/lib/cannbay-token-prompt'44+} from '@/lib/lake-token-prompt'
44 45 
45-interface CannbayTokenDialogProps {46+interface LakeTokenDialogProps {
46 open: boolean47 open: boolean
47 onOpenChange: (open: boolean) => void48 onOpenChange: (open: boolean) => void
48 /** 触发语境:no-credentials(未配置)/ auth-failed(令牌无效)/ forbidden(无写权限) */49 /** 触发语境:no-credentials(未配置)/ auth-failed(令牌无效)/ forbidden(无写权限) */
49- reason?: CannbayTokenReason50+ reason?: LakeTokenReason
50 /** 保存成功回调(调用方可刷新自身凭据状态) */51 /** 保存成功回调(调用方可刷新自身凭据状态) */
51 onSaved?: (info: { username: string; tokenMasked: string; verifiedAt?: string | null }) => void52 onSaved?: (info: { username: string; tokenMasked: string; verifiedAt?: string | null }) => void
52}53}
@@ -58,16 +59,21 @@ interface SavedReceipt {
58 reachable: boolean59 reachable: boolean
59}60}
60 61 
61-export function CannbayTokenDialog({ open, onOpenChange, reason = 'no-credentials', onSaved }: CannbayTokenDialogProps) {62+export function LakeTokenDialog({ open, onOpenChange, reason = 'no-credentials', onSaved }: LakeTokenDialogProps) {
62 const [username, setUsername] = useState('')63 const [username, setUsername] = useState('')
63 const [token, setToken] = useState('')64 const [token, setToken] = useState('')
65+ // unconfigured 语境:地址可编辑(保存写入服务端 lake-config.json——全机
66+ // 生效,与设置页同源)。其余语境地址只读展示(服务端配置的当前远端)
67+ const [addrInput, setAddrInput] = useState('')
68+ const [serverAddress, setServerAddress] = useState('')
64 const [showToken, setShowToken] = useState(false)69 const [showToken, setShowToken] = useState(false)
65 const [saving, setSaving] = useState(false)70 const [saving, setSaving] = useState(false)
66 const [error, setError] = useState<string | null>(null)71 const [error, setError] = useState<string | null>(null)
67 const [existing, setExisting] = useState<StoredRemoteInfo | null>(null)72 const [existing, setExisting] = useState<StoredRemoteInfo | null>(null)
68 const [saved, setSaved] = useState<SavedReceipt | null>(null)73 const [saved, setSaved] = useState<SavedReceipt | null>(null)
69 74 
70- const address = resolveRemoteAddress(loadCannbayConfig())75+ const editableAddr = reason === 'unconfigured'
76+ const address = editableAddr ? addrInput.trim() : serverAddress
71 const copy = credentialPromptCopy(reason)77 const copy = credentialPromptCopy(reason)
72 78 
73 // render-phase 打开沿检测(表单重置——effect 内同步 setState 触发级联渲染)79 // render-phase 打开沿检测(表单重置——effect 内同步 setState 触发级联渲染)
@@ -79,18 +85,21 @@ export function CannbayTokenDialog({ open, onOpenChange, reason = 'no-credential
79 setError(null)85 setError(null)
80 setSaved(null)86 setSaved(null)
81 setShowToken(false)87 setShowToken(false)
88+ setAddrInput('')
82 }89 }
83 }90 }
84 91 
85- // 已存凭据读取(替换语境:预填用户名 + 展示脱敏值)92+ // 服务端配置地址(只读语境展示 + 凭据匹配键);unconfigured 语境顺带探测
93+ // (保存后的重试由调用方触发,此处仅展示)
86 useEffect(() => {94 useEffect(() => {
87 if (!open) return95 if (!open) return
88 let active = true96 let active = true
89- fetch('/api/settings/cannbay-auth')97+ serverLakeConfigStatus().then(st => { if (active) setServerAddress(st.address) })
98+ fetch('/api/settings/lake-auth')
90 .then(r => r.json())99 .then(r => r.json())
91 .then((data: { remotes?: StoredRemoteInfo[] }) => {100 .then((data: { remotes?: StoredRemoteInfo[] }) => {
92 if (!active) return101 if (!active) return
93- const hit = findStoredAuthEntry(data.remotes ?? [], address)102+ const hit = address ? findStoredAuthEntry(data.remotes ?? [], address) : null
94 setExisting(hit)103 setExisting(hit)
95 if (hit?.username) setUsername(hit.username)104 if (hit?.username) setUsername(hit.username)
96 })105 })
@@ -99,13 +108,37 @@ export function CannbayTokenDialog({ open, onOpenChange, reason = 'no-credential
99 }, [open, address])108 }, [open, address])
100 109 
101 async function handleSave() {110 async function handleSave() {
111+ if (editableAddr && !address) {
112+ setError('请填写数据仓地址(https://…/your-lake.git 或本地裸仓路径)')
113+ return
114+ }
115+ if (editableAddr) {
116+ // 地址落服务端 lake-config.json(全机生效——与设置页同源),
117+ // 下次 list/upload/viewer 自动生效。失败必须 setError 显式反馈:
118+ // handleSave 由 onClick/Enter 直接调用(不 await),throw 落在
119+ // try/catch 之外只会变 unhandled rejection——弹窗零反馈(地址含
120+ // 非法字符被 PUT 400 拒时用户只看到按钮点了没反应)
121+ try {
122+ const cfgRes = await fetch('/api/settings/lake-config', {
123+ method: 'PUT',
124+ headers: { 'Content-Type': 'application/json' },
125+ body: JSON.stringify({ address: address.replace(/\/+$/, '') }),
126+ })
127+ // 非 JSON 错误响应(网关 502 HTML 等)json() 裸抛 → 落兜底文案
128+ const errBody = await cfgRes.json().catch(() => null) as { error?: string } | null
129+ if (!cfgRes.ok) throw new Error(String(errBody?.error ?? '地址保存失败'))
130+ } catch (e) {
131+ setError(e instanceof Error ? e.message : '网络错误')
132+ return
133+ }
134+ }
102 const u = username.trim() || 'oauth2'135 const u = username.trim() || 'oauth2'
103 const t = token.trim()136 const t = token.trim()
104 if (!t || saving || saved) return137 if (!t || saving || saved) return
105 setSaving(true)138 setSaving(true)
106 setError(null)139 setError(null)
107 try {140 try {
108- const res = await fetch('/api/settings/cannbay-auth', {141+ const res = await fetch('/api/settings/lake-auth', {
109 method: 'PUT',142 method: 'PUT',
110 headers: { 'Content-Type': 'application/json' },143 headers: { 'Content-Type': 'application/json' },
111 body: JSON.stringify({ url: address, username: u, token: t }),144 body: JSON.stringify({ url: address, username: u, token: t }),
@@ -166,7 +199,7 @@ export function CannbayTokenDialog({ open, onOpenChange, reason = 'no-credential
166 : 'bg-primary/10 text-foreground/80 ring-foreground/10')199 : 'bg-primary/10 text-foreground/80 ring-foreground/10')
167 }200 }
168 >201 >
169- {reason === 'forbidden' ? <ShieldAlertIcon className="size-5" /> : <KeyRoundIcon className="size-5" />}202+ {reason === 'forbidden' ? <ShieldAlertIcon className="size-5" /> : reason === 'unconfigured' ? <LinkIcon className="size-5" /> : <KeyRoundIcon className="size-5" />}
170 </span>203 </span>
171 <div className="min-w-0 space-y-1.5">204 <div className="min-w-0 space-y-1.5">
172 <DialogTitle>{copy.title}</DialogTitle>205 <DialogTitle>{copy.title}</DialogTitle>
@@ -175,10 +208,28 @@ export function CannbayTokenDialog({ open, onOpenChange, reason = 'no-credential
175 </DialogHeader>208 </DialogHeader>
176 209 
177 <div className="space-y-4 p-5">210 <div className="space-y-4 p-5">
178- <div className="flex items-center gap-2 rounded-lg bg-muted/60 px-3 py-2">211+ {editableAddr ? (
179- <span className="shrink-0 text-xs text-muted-foreground">远端</span>212+ <div className="space-y-1.5">
180- <span className="truncate font-mono text-xs text-foreground/80" title={address}>{address}</span>213+ <label htmlFor="cb-token-addr" className="text-xs font-medium text-foreground/80">数据仓地址</label>
181- </div>214+ <Input
215+ id="cb-token-addr"
216+ value={addrInput}
217+ onChange={(e) => setAddrInput(e.target.value)}
218+ placeholder="https://example.com/your-lake.git"
219+ className="h-8 font-mono text-xs"
220+ autoComplete="off"
221+ spellCheck={false}
222+ />
223+ <p className="text-[11px] leading-relaxed text-muted-foreground">
224+ 地址保存到本机服务端(全机生效:导入/上传/Viewer 共用),高级选项见「设置 → 数据之湖」。
225+ </p>
226+ </div>
227+ ) : (
228+ <div className="flex items-center gap-2 rounded-lg bg-muted/60 px-3 py-2">
229+ <span className="shrink-0 text-xs text-muted-foreground">远端</span>
230+ <span className="truncate font-mono text-xs text-foreground/80" title={address}>{address}</span>
231+ </div>
232+ )}
182 {existing && (233 {existing && (
183 <p className="text-xs text-muted-foreground">234 <p className="text-xs text-muted-foreground">
184 已配置 {existing.username} · {existing.tokenMasked} —— 输入新令牌将替换235 已配置 {existing.username} · {existing.tokenMasked} —— 输入新令牌将替换
@@ -235,9 +286,9 @@ export function CannbayTokenDialog({ open, onOpenChange, reason = 'no-credential
235 <Button variant="ghost" size="sm" className="h-8 text-xs" onClick={() => onOpenChange(false)} disabled={busy}>286 <Button variant="ghost" size="sm" className="h-8 text-xs" onClick={() => onOpenChange(false)} disabled={busy}>
236 取消287 取消
237 </Button>288 </Button>
238- <Button size="sm" className="h-8 text-xs" onClick={handleSave} disabled={busy || !token.trim()}>289+ <Button size="sm" className="h-8 text-xs" onClick={handleSave} disabled={busy || !token.trim() || (editableAddr && !address)}>
239 {saving ? <Loader2Icon className="size-3.5 animate-spin" /> : null}290 {saving ? <Loader2Icon className="size-3.5 animate-spin" /> : null}
240- {saving ? '验证并保存…' : '保存令牌'}291+ {saving ? '验证并保存…' : editableAddr ? '连接数据仓' : '保存令牌'}
241 </Button>292 </Button>
242 </div>293 </div>
243 </>294 </>
@@ -9,8 +9,8 @@
9 9 
10import { useState, useEffect, useCallback } from "react"10import { useState, useEffect, useCallback } from "react"
11import { BRAND_NAME, BRAND_SLUG, BRAND_SOURCE_TYPE, BAY_NAME } from '@/lib/branding'11import { BRAND_NAME, BRAND_SLUG, BRAND_SOURCE_TYPE, BAY_NAME } from '@/lib/branding'
12-import { loadCannbayConfig, cannbayRemoteInput, isDefaultCannbayRemote, DEFAULT_CANNBAY_ADDRESS } from '@/lib/cannbay-config'12+import { serverLakeConfigStatus } from '@/lib/lake-token-prompt'
13-import { CannbayTokenDialog } from "@/components/CannbayTokenDialog"13+import { LakeTokenDialog } from "@/components/LakeTokenDialog"
14import {14import {
15 Dialog,15 Dialog,
16 DialogContent,16 DialogContent,
@@ -97,7 +97,7 @@ export function sortDirEntries(entries: DirEntry[], asc: boolean): DirEntry[] {
97 })97 })
98}98}
99 99 
100-interface CannbaySession {100+interface LakeSession {
101 sid: string101 sid: string
102 fileCount: number102 fileCount: number
103 author: string103 author: string
@@ -118,7 +118,7 @@ interface CannbaySession {
118}118}
119 119 
120/** 同 sid 双记录:列表选择键 = sid@recording(同一 sid 的捕获与原生各占一条目) */120/** 同 sid 双记录:列表选择键 = sid@recording(同一 sid 的捕获与原生各占一条目) */
121-function cannbayEntryKey(s: Pick<CannbaySession, 'sid' | 'recording'>): string {121+function lakeEntryKey(s: Pick<LakeSession, 'sid' | 'recording'>): string {
122 return `${s.sid}@${s.recording ?? 'capture'}`122 return `${s.sid}@${s.recording ?? 'capture'}`
123}123}
124 124 
@@ -128,7 +128,7 @@ function previewOf(previews: SessionPreview[], s: { sessionId: string; recording
128 return previews.find(p => p.id === s.sessionId && (p.recording ?? undefined) === (s.recording ?? undefined))128 return previews.find(p => p.id === s.sessionId && (p.recording ?? undefined) === (s.recording ?? undefined))
129}129}
130 130 
131-type Step = "input" | "browse" | "select" | "cannbay" | "importing"131+type Step = "input" | "browse" | "select" | "lake" | "importing"
132 132 
133function formatSize(size: number): string {133function formatSize(size: number): string {
134 if (size < 1024) return `${size}B`134 if (size < 1024) return `${size}B`
@@ -151,8 +151,8 @@ function formatTokens(n: number): string {
151 return `${(n / 1000000).toFixed(2)}M`151 return `${(n / 1000000).toFixed(2)}M`
152}152}
153 153 
154-// cannbay2 路由错误(带结构化 code):凭据类错误附设置页指引,其余原样154+// lake 路由错误(带结构化 code):凭据类错误附设置页指引,其余原样
155-function cannbayErrorMessage(data: { error?: string; code?: string }): string {155+function lakeErrorMessage(data: { error?: string; code?: string }): string {
156 const base = String(data.error ?? "Import failed")156 const base = String(data.error ?? "Import failed")
157 if (["no-credentials", "auth-failed", "forbidden"].includes(data.code ?? "")) {157 if (["no-credentials", "auth-failed", "forbidden"].includes(data.code ?? "")) {
158 return `${base} —— 请在弹窗中配置凭据后重试`158 return `${base} —— 请在弹窗中配置凭据后重试`
@@ -164,7 +164,7 @@ export function LocalFileImport() {
164 const [open, setOpen] = useState(false)164 const [open, setOpen] = useState(false)
165 const [step, setStep] = useState<Step>("input")165 const [step, setStep] = useState<Step>("input")
166 const [filePath, setFilePath] = useState("/")166 const [filePath, setFilePath] = useState("/")
167- const [sourceType, setSourceType] = useState<"opencode-db" | "claude-jsonl" | "cannbay" | typeof BRAND_SOURCE_TYPE>("claude-jsonl")167+ const [sourceType, setSourceType] = useState<"opencode-db" | "claude-jsonl" | "lake" | typeof BRAND_SOURCE_TYPE>("claude-jsonl")
168 const [sessions, setSessions] = useState<SessionPreview[]>([])168 const [sessions, setSessions] = useState<SessionPreview[]>([])
169 const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())169 const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
170 // 会话选择步的 keyword/sessionID 过滤(firstQuery 或 id 子串,大小写不敏感)170 // 会话选择步的 keyword/sessionID 过滤(firstQuery 或 id 子串,大小写不敏感)
@@ -177,21 +177,23 @@ export function LocalFileImport() {
177 const [currentDir, setCurrentDir] = useState<string | null>(null)177 const [currentDir, setCurrentDir] = useState<string | null>(null)
178 const [parentDir, setParentDir] = useState<string | null>(null)178 const [parentDir, setParentDir] = useState<string | null>(null)
179 const [browseLoading, setBrowseLoading] = useState(false)179 const [browseLoading, setBrowseLoading] = useState(false)
180- // Load Sessions 扫描目录/读库期间的前端反馈(对齐 browseLoading/cannbayLoading)180+ // Load Sessions 扫描目录/读库期间的前端反馈(对齐 browseLoading/lakeLoading)
181 const [sessionsLoading, setSessionsLoading] = useState(false)181 const [sessionsLoading, setSessionsLoading] = useState(false)
182- const [cannbaySessions, setCannbaySessions] = useState<CannbaySession[]>([])182+ const [lakeSessions, setLakeSessions] = useState<LakeSession[]>([])
183- const [cannbayScope, setCannbayScope] = useState("")183+ const [lakeScope, setLakeScope] = useState("")
184- const [cannbaySelected, setCannbaySelected] = useState<Set<string>>(new Set())184+ const [lakeSelected, setLakeSelected] = useState<Set<string>>(new Set())
185- const [cannbayLoading, setCannbayLoading] = useState(false)185+ const [lakeLoading, setLakeLoading] = useState(false)
186- const [cannbayFilter, setCannbayFilter] = useState("")186+ const [lakeFilter, setLakeFilter] = useState("")
187+ // 服务端单源配置快照(handleOpenLake 时取回:scope 展示 + File Path 列拼路径)
188+ const [lakeRemoteInfo, setLakeRemoteInfo] = useState<{ address: string; branch: string }>({ address: "", branch: "main" })
187 // 范式优先导入:先选范式目录(数据仓顶层目录即注册表),再看该范式下会话。189 // 范式优先导入:先选范式目录(数据仓顶层目录即注册表),再看该范式下会话。
188- // null = 范式选择阶段;'__none__' = 未标注(存量 sessions/ 区);其余 = 范式目录名190+ // null = 范式选择阶段;'__none__' = 根目录(根级会话,缺省上传位);其余 = 范式目录名
189- const [cannbayParadigms, setCannbayParadigms] = useState<string[]>([])191+ const [lakeParadigms, setLakeParadigms] = useState<string[]>([])
190- const [cannbayParadigm, setCannbayParadigm] = useState<string | null>(null)192+ const [lakeParadigm, setLakeParadigm] = useState<string | null>(null)
191 // 数据仓凭据引导弹窗:列表/导入的凭据类失败(私有仓无凭据/193 // 数据仓凭据引导弹窗:列表/导入的凭据类失败(私有仓无凭据/
192 // 凭据无效)反应弹出;保存走服务端,一次配置全机生效194 // 凭据无效)反应弹出;保存走服务端,一次配置全机生效
193 const [tokenDialogOpen, setTokenDialogOpen] = useState(false)195 const [tokenDialogOpen, setTokenDialogOpen] = useState(false)
194- const [tokenReason, setTokenReason] = useState<"no-credentials" | "auth-failed" | "forbidden">("no-credentials")196+ const [tokenReason, setTokenReason] = useState<"unconfigured" | "no-credentials" | "auth-failed" | "forbidden">("no-credentials")
195 197 
196 function resetState() {198 function resetState() {
197 setStep("input")199 setStep("input")
@@ -207,12 +209,12 @@ export function LocalFileImport() {
207 setParentDir(null)209 setParentDir(null)
208 setBrowseLoading(false)210 setBrowseLoading(false)
209 setSessionsLoading(false)211 setSessionsLoading(false)
210- setCannbaySessions([])212+ setLakeSessions([])
211- setCannbaySelected(new Set())213+ setLakeSelected(new Set())
212- setCannbayLoading(false)214+ setLakeLoading(false)
213- setCannbayFilter("")215+ setLakeFilter("")
214- setCannbayParadigms([])216+ setLakeParadigms([])
215- setCannbayParadigm(null)217+ setLakeParadigm(null)
216 }218 }
217 219 
218 async function browseDirectory(dirPath: string) {220 async function browseDirectory(dirPath: string) {
@@ -388,24 +390,30 @@ export function LocalFileImport() {
388 // (后端单根模式,任意顶层目录不是范式注册表)——只拉列表,直接进会话表。390 // (后端单根模式,任意顶层目录不是范式注册表)——只拉列表,直接进会话表。
389 // list 一次请求同时返回 sessions + dirs(顶层目录):后端单轮 mirror fetch391 // list 一次请求同时返回 sessions + dirs(顶层目录):后端单轮 mirror fetch
390 // 服务两份数据(原先 dirs/list 两路各 fetch 一次 ~1s×2)392 // 服务两份数据(原先 dirs/list 两路各 fetch 一次 ~1s×2)
391- async function handleOpenCannbay() {393+ async function handleOpenLake() {
392- setCannbayLoading(true)394+ // 未配置预检:服务端 lake-config.json(单源)未配 → 弹引导(unconfigured
395+ // 语境带地址输入),保存后经 onSaved 自动重试本函数——首次使用全程不
396+ // 离开导入流
397+ const server = await serverLakeConfigStatus()
398+ if (!server.configured) {
399+ setTokenReason('unconfigured')
400+ setTokenDialogOpen(true)
401+ return
402+ }
403+ setLakeRemoteInfo({ address: server.address, branch: server.branch })
404+ setLakeLoading(true)
393 setError(null)405 setError(null)
394- setCannbaySessions([])406+ setLakeSessions([])
395- setCannbaySelected(new Set())407+ setLakeSelected(new Set())
396- setCannbayParadigms([])408+ setLakeParadigms([])
397- setCannbayParadigm(null)409+ setLakeParadigm(null)
398- const cfg = loadCannbayConfig()410+ const paradigmEnabled = true
399- const paradigmEnabled = isDefaultCannbayRemote(cfg)411+ setLakeScope(`${server.address || "服务端配置"} · 分支 ${server.branch}`)
400- const addr = cfg?.address.trim() && cfg.address.trim() !== DEFAULT_CANNBAY_ADDRESS ? cfg.address.trim() : "默认仓"
401- setCannbayScope(paradigmEnabled
402- ? `${addr} · 分支 ${cfg?.branch.trim() || "main"}`
403- : `${addr} · 分支 ${cfg?.branch.trim() || "main"} · 目录 /${cfg?.directory.trim() || "sessions"}`)
404 try {412 try {
405- const listRes = await fetch("/api/ingest/cannbay2", {413+ const listRes = await fetch("/api/ingest/lake", {
406 method: "POST",414 method: "POST",
407 headers: { "Content-Type": "application/json" },415 headers: { "Content-Type": "application/json" },
408- body: JSON.stringify({ action: "list", ...cannbayRemoteInput(cfg) }),416+ body: JSON.stringify({ action: "list" }),
409 })417 })
410 const listData = await listRes.json()418 const listData = await listRes.json()
411 if (!listRes.ok) {419 if (!listRes.ok) {
@@ -414,35 +422,38 @@ export function LocalFileImport() {
414 setTokenReason(listData.code as "no-credentials" | "auth-failed" | "forbidden")422 setTokenReason(listData.code as "no-credentials" | "auth-failed" | "forbidden")
415 setTokenDialogOpen(true)423 setTokenDialogOpen(true)
416 }424 }
417- throw new Error(cannbayErrorMessage(listData))425+ throw new Error(lakeErrorMessage(listData))
418 }426 }
419 if (paradigmEnabled) {427 if (paradigmEnabled) {
420- const paradigms = ((listData.dirs ?? []) as string[]).filter(d => d !== "sessions")428+ // 全仓顶层目录即范式注册表(sessions/ 存量区特判已退役);根级
429+ // 会话目录(缺省上传位)不进注册表——按会话清单剔除
430+ const rootFolders = new Set((listData.sessions ?? []).filter((s: LakeSession) => !s.paradigm).map((s: LakeSession) => s.sid + (s.recording === "native" ? ".native" : "")))
431+ const paradigms = ((listData.dirs ?? []) as string[]).filter(d => !rootFolders.has(d))
421 if (paradigms.length === 0) {432 if (paradigms.length === 0) {
422 setError("数据仓暂无范式目录:请先在数据仓顶层新建范式目录")433 setError("数据仓暂无范式目录:请先在数据仓顶层新建范式目录")
423 return434 return
424 }435 }
425- setCannbayParadigms(paradigms)436+ setLakeParadigms(paradigms)
426 }437 }
427- setCannbaySessions(listData.sessions ?? [])438+ setLakeSessions(listData.sessions ?? [])
428- // 自定义远端直接进会话表(未标注 = 单根全量);默认仓停在范式选择阶段439+ // 恒范式优先:停在范式选择阶段(选中后本地过滤,切换零延迟)
429- setCannbayParadigm(paradigmEnabled ? null : "__none__")440+ setLakeParadigm(null)
430- setStep("cannbay")441+ setStep("lake")
431 } catch (e) {442 } catch (e) {
432 setError(e instanceof Error ? e.message : "Network error")443 setError(e instanceof Error ? e.message : "Network error")
433 } finally {444 } finally {
434- setCannbayLoading(false)445+ setLakeLoading(false)
435 }446 }
436 }447 }
437 448 
438- function pickCannbayParadigm(p: string) {449+ function pickLakeParadigm(p: string) {
439- setCannbayParadigm(p)450+ setLakeParadigm(p)
440- setCannbayFilter("")451+ setLakeFilter("")
441- setCannbaySelected(new Set())452+ setLakeSelected(new Set())
442 }453 }
443 454 
444- function toggleCannbaySelection(filename: string) {455+ function toggleLakeSelection(filename: string) {
445- setCannbaySelected(prev => {456+ setLakeSelected(prev => {
446 const next = new Set(prev)457 const next = new Set(prev)
447 if (next.has(filename)) next.delete(filename)458 if (next.has(filename)) next.delete(filename)
448 else next.add(filename)459 else next.add(filename)
@@ -450,21 +461,21 @@ export function LocalFileImport() {
450 })461 })
451 }462 }
452 463 
453- async function handleImportCannbay() {464+ async function handleImportLake() {
454- if (cannbaySelected.size === 0) return465+ if (lakeSelected.size === 0) return
455 setStep("importing")466 setStep("importing")
456 467 
457 // 选择键 = sid@recording(同 sid 双记录各一条目);导入按各自记录类定位目录。468 // 选择键 = sid@recording(同 sid 双记录各一条目);导入按各自记录类定位目录。
458 // 复合键只用于选择集与状态更新定位(key 字段)——sessionId 一律真实 sid:469 // 复合键只用于选择集与状态更新定位(key 字段)——sessionId 一律真实 sid:
459 // 共享路径(ImportHistory taskId / dbMeta / 列表 label / delete-session)470 // 共享路径(ImportHistory taskId / dbMeta / 列表 label / delete-session)
460 // 全部按裸 sid 消费,复合键外泄会让删除 404 静默失败、File Path 列错显471 // 全部按裸 sid 消费,复合键外泄会让删除 404 静默失败、File Path 列错显
461- const keys = Array.from(cannbaySelected)472+ const keys = Array.from(lakeSelected)
462- const byKey = new Map(cannbaySessions.map(s => [cannbayEntryKey(s), s]))473+ const byKey = new Map(lakeSessions.map(s => [lakeEntryKey(s), s]))
463 const selectedEntries = keys474 const selectedEntries = keys
464 .map(k => byKey.get(k))475 .map(k => byKey.get(k))
465- .filter((s): s is CannbaySession => s != null)476+ .filter((s): s is LakeSession => s != null)
466 setImportStatuses(selectedEntries.map(e => ({477 setImportStatuses(selectedEntries.map(e => ({
467- key: cannbayEntryKey(e),478+ key: lakeEntryKey(e),
468 sessionId: e.sid,479 sessionId: e.sid,
469 recording: e.recording ?? 'capture',480 recording: e.recording ?? 'capture',
470 paradigm: e.paradigm ?? null,481 paradigm: e.paradigm ?? null,
@@ -481,10 +492,10 @@ export function LocalFileImport() {
481 const sid = entry?.sid ?? key.split('@')[0]492 const sid = entry?.sid ?? key.split('@')[0]
482 const recording = entry?.recording ?? 'capture'493 const recording = entry?.recording ?? 'capture'
483 try {494 try {
484- const res = await fetch("/api/ingest/cannbay2", {495+ const res = await fetch("/api/ingest/lake", {
485 method: "POST",496 method: "POST",
486 headers: { "Content-Type": "application/json" },497 headers: { "Content-Type": "application/json" },
487- body: JSON.stringify({ action: "import", sid, recording, ...cannbayRemoteInput(loadCannbayConfig()) }),498+ body: JSON.stringify({ action: "import", sid, recording }),
488 })499 })
489 const data = await res.json()500 const data = await res.json()
490 const result = res.ok ? data.result : null501 const result = res.ok ? data.result : null
@@ -495,7 +506,7 @@ export function LocalFileImport() {
495 }506 }
496 setImportStatuses(prev => prev.map(s =>507 setImportStatuses(prev => prev.map(s =>
497 s.key === key508 s.key === key
498- ? { ...s, status: ok ? "success" as const : "error" as const, message: ok ? importResultMessage(result) : cannbayErrorMessage(data), framework: result?.framework ?? undefined, version: result?.version ?? undefined }509+ ? { ...s, status: ok ? "success" as const : "error" as const, message: ok ? importResultMessage(result) : lakeErrorMessage(data), framework: result?.framework ?? undefined, version: result?.version ?? undefined }
499 : s))510 : s))
500 if (ok) {511 if (ok) {
501 importedPreviews.push({512 importedPreviews.push({
@@ -615,18 +626,13 @@ export function LocalFileImport() {
615 const handleDone = useCallback(() => {626 const handleDone = useCallback(() => {
616 const hasSuccess = importStatuses.some(s => s.status === "success")627 const hasSuccess = importStatuses.some(s => s.status === "success")
617 if (hasSuccess) {628 if (hasSuccess) {
618- // File Path 列显示真实仓路径:目录 = 范式目录(默认仓条目实际在629+ // File Path 列显示真实仓路径:目录 = 范式目录(条目实际在
619- // <paradigm>/<sid>[.native]/,存量区/自定义远端回落 cfg.directory),630+ // <paradigm>/<sid>[.native]/,根级会话回落 cfg.directory || 根),
620 // 记录类决定 <sid> vs <sid>.native631 // 记录类决定 <sid> vs <sid>.native
621- const cannbayRepoRef = (sid: string, recording?: "capture" | "native", paradigm?: string | null): string => {632+ const lakeRepoRef = (sid: string, recording?: "capture" | "native", paradigm?: string | null): string => {
622- const cfg = loadCannbayConfig()
623- const addr = cfg?.address.trim() && cfg.address.trim() !== DEFAULT_CANNBAY_ADDRESS
624- ? cfg.address.trim()
625- : DEFAULT_CANNBAY_ADDRESS
626- const branch = cfg?.branch.trim() || "main"
627- const dir = paradigm ?? (cfg?.directory.trim() || "sessions")
628 const dirName = recording === "native" ? `${sid}.native` : sid633 const dirName = recording === "native" ? `${sid}.native` : sid
629- return `${BAY_NAME}@${addr.replace(/^https?:\/\//, "")}@${branch}:${dir}/${dirName}`634+ const addr = lakeRemoteInfo.address.replace(/^https?:\/\//, "")
635+ return `${BAY_NAME}@${addr}@${lakeRemoteInfo.branch}:${paradigm ? `${paradigm}/` : ""}${dirName}`
630 }636 }
631 const entries: ImportHistoryEntry[] = importStatuses.map(s => {637 const entries: ImportHistoryEntry[] = importStatuses.map(s => {
632 const session = previewOf(sessions, s)638 const session = previewOf(sessions, s)
@@ -638,8 +644,8 @@ export function LocalFileImport() {
638 turnCount: session?.turnCount ?? 0,644 turnCount: session?.turnCount ?? 0,
639 status: s.status === "success" ? "success" as const : "error" as const,645 status: s.status === "success" ? "success" as const : "error" as const,
640 query: session?.firstQuery ?? null,646 query: session?.firstQuery ?? null,
641- filePath: sourceType === "cannbay" ? cannbayRepoRef(s.sessionId, s.recording, s.paradigm) : filePath.trim(),647+ filePath: sourceType === "lake" ? lakeRepoRef(s.sessionId, s.recording, s.paradigm) : filePath.trim(),
642- sourceType: sourceType === "cannbay" ? BRAND_SOURCE_TYPE : sourceType,648+ sourceType: sourceType === "lake" ? BRAND_SOURCE_TYPE : sourceType,
643 framework: s.framework ?? null,649 framework: s.framework ?? null,
644 version: s.version ?? null,650 version: s.version ?? null,
645 }651 }
@@ -687,7 +693,7 @@ export function LocalFileImport() {
687 {step === "input" && `Import session data from local files or ${BAY_NAME}`}693 {step === "input" && `Import session data from local files or ${BAY_NAME}`}
688 {step === "browse" && "Browse directories and select a file to import"}694 {step === "browse" && "Browse directories and select a file to import"}
689 {step === "select" && "Select sessions to import"}695 {step === "select" && "Select sessions to import"}
690- {step === "cannbay" && (cannbayParadigm == null ? "选择范式目录(数据仓顶层目录)" : "选择该范式下的会话导入")}696+ {step === "lake" && (lakeParadigm == null ? "选择范式目录(数据仓顶层目录)" : "选择该范式下的会话导入")}
691 {step === "importing" && (allDone697 {step === "importing" && (allDone
692 ? (hasError ? "Import finished — some sessions failed (details above)" : "Import complete")698 ? (hasError ? "Import finished — some sessions failed (details above)" : "Import complete")
693 : `Importing ${importStatuses.filter(s => s.status === "success" || s.status === "error").length}/${importStatuses.length} sessions...`)}699 : `Importing ${importStatuses.filter(s => s.status === "success" || s.status === "error").length}/${importStatuses.length} sessions...`)}
@@ -714,16 +720,16 @@ export function LocalFileImport() {
714 SQLite (auto)720 SQLite (auto)
715 </Button>721 </Button>
716 <Button722 <Button
717- variant={sourceType === "cannbay" ? "default" : "outline"}723+ variant={sourceType === "lake" ? "default" : "outline"}
718 size="sm"724 size="sm"
719- onClick={() => { setSourceType("cannbay"); handleOpenCannbay() }}725+ onClick={() => { setSourceType("lake"); handleOpenLake() }}
720 >726 >
721 <GlobeIcon className="size-3.5 mr-1" />727 <GlobeIcon className="size-3.5 mr-1" />
722 {BAY_NAME}728 {BAY_NAME}
723 </Button>729 </Button>
724 </div>730 </div>
725 </div>731 </div>
726- {sourceType !== "cannbay" && (732+ {sourceType !== "lake" && (
727 <div>733 <div>
728 <label className="text-sm font-medium mb-1.5 block">File Path</label>734 <label className="text-sm font-medium mb-1.5 block">File Path</label>
729 <div className="flex gap-2">735 <div className="flex gap-2">
@@ -790,20 +796,20 @@ export function LocalFileImport() {
790 </div>796 </div>
791 </div>797 </div>
792 )}798 )}
793- {sourceType === "cannbay" && !cannbayLoading && cannbayParadigms.length === 0 && !error && (799+ {sourceType === "lake" && !lakeLoading && lakeParadigms.length === 0 && !error && (
794 <div className="text-sm text-muted-foreground py-4 text-center">800 <div className="text-sm text-muted-foreground py-4 text-center">
795 <GlobeIcon className="size-5 mx-auto mb-2 text-blue-500" />801 <GlobeIcon className="size-5 mx-auto mb-2 text-blue-500" />
796 <p>从 {BAY_NAME} 数据仓选择范式目录,导入该范式下的会话</p>802 <p>从 {BAY_NAME} 数据仓选择范式目录,导入该范式下的会话</p>
797 <p className="text-xs mt-1">范式目录 = 数据仓顶层目录;其他用户上传的会话按范式归类</p>803 <p className="text-xs mt-1">范式目录 = 数据仓顶层目录;其他用户上传的会话按范式归类</p>
798 </div>804 </div>
799 )}805 )}
800- {sourceType === "cannbay" && cannbayLoading && (806+ {sourceType === "lake" && lakeLoading && (
801 <div className="flex items-center gap-2 py-4 justify-center">807 <div className="flex items-center gap-2 py-4 justify-center">
802 <LoaderIcon className="size-4 animate-spin" />808 <LoaderIcon className="size-4 animate-spin" />
803 <span className="text-sm text-muted-foreground">Fetching sessions from {BAY_NAME}...</span>809 <span className="text-sm text-muted-foreground">Fetching sessions from {BAY_NAME}...</span>
804 </div>810 </div>
805 )}811 )}
806- {sourceType !== "cannbay" && sessionsLoading && (812+ {sourceType !== "lake" && sessionsLoading && (
807 <div className="flex items-center gap-2 py-4 justify-center">813 <div className="flex items-center gap-2 py-4 justify-center">
808 <LoaderIcon className="size-4 animate-spin" />814 <LoaderIcon className="size-4 animate-spin" />
809 <span className="text-sm text-muted-foreground">Loading sessions...</span>815 <span className="text-sm text-muted-foreground">Loading sessions...</span>
@@ -985,22 +991,22 @@ export function LocalFileImport() {
985 </div>991 </div>
986 )}992 )}
987 993 
988- {step === "cannbay" && cannbayParadigm == null && (994+ {step === "lake" && lakeParadigm == null && (
989 <div className="space-y-3">995 <div className="space-y-3">
990- <p className="text-xs text-muted-foreground">当前远端:{cannbayScope}</p>996+ <p className="text-xs text-muted-foreground">当前远端:{lakeScope}</p>
991 <div className="grid grid-cols-3 gap-2 max-h-[320px] overflow-y-auto">997 <div className="grid grid-cols-3 gap-2 max-h-[320px] overflow-y-auto">
992- {cannbaySessions.some(s => !s.paradigm) && (998+ {lakeSessions.some(s => !s.paradigm) && (
993- <Button variant="outline" size="sm" className="justify-between h-auto py-2" onClick={() => pickCannbayParadigm("__none__")}>999+ <Button variant="outline" size="sm" className="justify-between h-auto py-2" onClick={() => pickLakeParadigm("__none__")}>
994- <span className="truncate">未标注</span>1000+ <span className="truncate">根目录</span>
995- <span className="text-xs text-muted-foreground shrink-0 ml-1">{cannbaySessions.filter(s => !s.paradigm).length}</span>1001+ <span className="text-xs text-muted-foreground shrink-0 ml-1">{lakeSessions.filter(s => !s.paradigm).length}</span>
996 </Button>1002 </Button>
997 )}1003 )}
998- {cannbayParadigms.map(p => (1004+ {lakeParadigms.map(p => (
999- <Button key={p} variant="outline" size="sm" className="justify-between h-auto py-2" onClick={() => pickCannbayParadigm(p)}>1005+ <Button key={p} variant="outline" size="sm" className="justify-between h-auto py-2" onClick={() => pickLakeParadigm(p)}>
1000 <span className="truncate" title={p}>{p}</span>1006 <span className="truncate" title={p}>{p}</span>
1001 <span className="text-xs text-muted-foreground shrink-0 ml-1">1007 <span className="text-xs text-muted-foreground shrink-0 ml-1">
1002- {cannbaySessions.some(s => s.isBaseline && s.paradigm === p) ? '基准✓ ' : ''}1008+ {lakeSessions.some(s => s.isBaseline && s.paradigm === p) ? '基准✓ ' : ''}
1003- {cannbaySessions.filter(s => s.paradigm === p).length}1009+ {lakeSessions.filter(s => s.paradigm === p).length}
1004 </span>1010 </span>
1005 </Button>1011 </Button>
1006 ))}1012 ))}
@@ -1010,26 +1016,26 @@ export function LocalFileImport() {
1010 </div>1016 </div>
1011 )}1017 )}
1012 1018 
1013- {step === "cannbay" && cannbayParadigm != null && (1019+ {step === "lake" && lakeParadigm != null && (
1014 <div className="space-y-3">1020 <div className="space-y-3">
1015 <p className="text-xs text-muted-foreground">1021 <p className="text-xs text-muted-foreground">
1016- 当前远端:{cannbayScope}1022+ 当前远端:{lakeScope}
1017- {cannbayParadigms.length > 0 && (1023+ {lakeParadigms.length > 0 && (
1018- <> · 范式目录 <span className="font-medium text-foreground">{cannbayParadigm === "__none__" ? "未标注(存量区)" : cannbayParadigm}</span></>1024+ <> · 范式目录 <span className="font-medium text-foreground">{lakeParadigm === "__none__" ? "根目录" : lakeParadigm}</span></>
1019 )}1025 )}
1020 </p>1026 </p>
1021 <div className="flex items-center gap-2">1027 <div className="flex items-center gap-2">
1022 <Input1028 <Input
1023 placeholder="Search by query, user, commit message, or task ID..."1029 placeholder="Search by query, user, commit message, or task ID..."
1024- value={cannbayFilter}1030+ value={lakeFilter}
1025- onChange={(e) => setCannbayFilter(e.target.value)}1031+ onChange={(e) => setLakeFilter(e.target.value)}
1026 className="flex-1"1032 className="flex-1"
1027 />1033 />
1028 <SearchIcon className="size-4 text-muted-foreground shrink-0" />1034 <SearchIcon className="size-4 text-muted-foreground shrink-0" />
1029 </div>1035 </div>
1030 {(() => {1036 {(() => {
1031- const q = cannbayFilter.toLowerCase()1037+ const q = lakeFilter.toLowerCase()
1032- const scoped = cannbaySessions.filter(s => (s.paradigm ?? "__none__") === cannbayParadigm)1038+ const scoped = lakeSessions.filter(s => (s.paradigm ?? "__none__") === lakeParadigm)
1033 const filtered = scoped1039 const filtered = scoped
1034 .filter(s =>1040 .filter(s =>
1035 !q || s.description?.toLowerCase().includes(q)1041 !q || s.description?.toLowerCase().includes(q)
@@ -1040,22 +1046,22 @@ export function LocalFileImport() {
1040 .slice()1046 .slice()
1041 // baseline 会话(该范式评分基准)置顶,其余按提交时间倒序1047 // baseline 会话(该范式评分基准)置顶,其余按提交时间倒序
1042 .sort((a, b) => Number(b.isBaseline === true) - Number(a.isBaseline === true) || (b.commitTime || "").localeCompare(a.commitTime || ""))1048 .sort((a, b) => Number(b.isBaseline === true) - Number(a.isBaseline === true) || (b.commitTime || "").localeCompare(a.commitTime || ""))
1043- const allFilteredSelected = filtered.length > 0 && filtered.every(s => cannbaySelected.has(cannbayEntryKey(s)))1049+ const allFilteredSelected = filtered.length > 0 && filtered.every(s => lakeSelected.has(lakeEntryKey(s)))
1044 return (1050 return (
1045 <>1051 <>
1046 <div className="flex items-center gap-2 mb-2">1052 <div className="flex items-center gap-2 mb-2">
1047 <Checkbox checked={allFilteredSelected} onCheckedChange={() => {1053 <Checkbox checked={allFilteredSelected} onCheckedChange={() => {
1048- // 键与行选择一致(cannbayEntryKey):同 sid 双记录两条目各自独立勾选1054+ // 键与行选择一致(lakeEntryKey):同 sid 双记录两条目各自独立勾选
1049 if (allFilteredSelected) {1055 if (allFilteredSelected) {
1050- setCannbaySelected(prev => {1056+ setLakeSelected(prev => {
1051 const next = new Set(prev)1057 const next = new Set(prev)
1052- for (const s of filtered) next.delete(cannbayEntryKey(s))1058+ for (const s of filtered) next.delete(lakeEntryKey(s))
1053 return next1059 return next
1054 })1060 })
1055 } else {1061 } else {
1056- setCannbaySelected(prev => {1062+ setLakeSelected(prev => {
1057 const next = new Set(prev)1063 const next = new Set(prev)
1058- for (const s of filtered) next.add(cannbayEntryKey(s))1064+ for (const s of filtered) next.add(lakeEntryKey(s))
1059 return next1065 return next
1060 })1066 })
1061 }1067 }
@@ -1086,9 +1092,9 @@ export function LocalFileImport() {
1086 </TableRow>1092 </TableRow>
1087 )}1093 )}
1088 {filtered.map(s => (1094 {filtered.map(s => (
1089- <TableRow key={cannbayEntryKey(s)} className={cannbaySelected.has(cannbayEntryKey(s)) ? "bg-primary/5" : ""}>1095+ <TableRow key={lakeEntryKey(s)} className={lakeSelected.has(lakeEntryKey(s)) ? "bg-primary/5" : ""}>
1090 <TableCell className="px-2 w-[36px]">1096 <TableCell className="px-2 w-[36px]">
1091- <Checkbox checked={cannbaySelected.has(cannbayEntryKey(s))} onCheckedChange={() => toggleCannbaySelection(cannbayEntryKey(s))} />1097+ <Checkbox checked={lakeSelected.has(lakeEntryKey(s))} onCheckedChange={() => toggleLakeSelection(lakeEntryKey(s))} />
1092 </TableCell>1098 </TableCell>
1093 <TableCell className="px-2 text-xs py-2 align-middle">1099 <TableCell className="px-2 text-xs py-2 align-middle">
1094 <span className="block truncate" title={s.description || s.sid}>1100 <span className="block truncate" title={s.description || s.sid}>
@@ -1148,7 +1154,7 @@ export function LocalFileImport() {
1148 </>1154 </>
1149 )1155 )
1150 })()}1156 })()}
1151- {cannbaySelected.size === 0 && (1157+ {lakeSelected.size === 0 && (
1152 <p className="text-sm text-muted-foreground">Select at least one session to import</p>1158 <p className="text-sm text-muted-foreground">Select at least one session to import</p>
1153 )}1159 )}
1154 {error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}1160 {error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
@@ -1207,7 +1213,7 @@ export function LocalFileImport() {
1207 )}1213 )}
1208 1214 
1209 <DialogFooter>1215 <DialogFooter>
1210- {step === "input" && sourceType !== "cannbay" && (1216+ {step === "input" && sourceType !== "lake" && (
1211 <Button onClick={handleLoadSessions} disabled={!filePath.trim() || sessionsLoading}>1217 <Button onClick={handleLoadSessions} disabled={!filePath.trim() || sessionsLoading}>
1212 {sessionsLoading ? (1218 {sessionsLoading ? (
1213 <span className="flex items-center gap-1.5">1219 <span className="flex items-center gap-1.5">
@@ -1219,9 +1225,9 @@ export function LocalFileImport() {
1219 )}1225 )}
1220 </Button>1226 </Button>
1221 )}1227 )}
1222- {step === "input" && sourceType === "cannbay" && (1228+ {step === "input" && sourceType === "lake" && (
1223- <Button onClick={handleOpenCannbay} disabled={cannbayLoading}>1229+ <Button onClick={handleOpenLake} disabled={lakeLoading}>
1224- {cannbayLoading ? "Fetching..." : `Fetch from ${BAY_NAME}`}1230+ {lakeLoading ? "Fetching..." : `Fetch from ${BAY_NAME}`}
1225 </Button>1231 </Button>
1226 )}1232 )}
1227 {step === "select" && (1233 {step === "select" && (
@@ -1234,24 +1240,24 @@ export function LocalFileImport() {
1234 </Button>1240 </Button>
1235 </>1241 </>
1236 )}1242 )}
1237- {step === "cannbay" && cannbayParadigm == null && (1243+ {step === "lake" && lakeParadigm == null && (
1238- <Button variant="outline" onClick={() => { setStep("input"); setSourceType("cannbay"); setError(null) }}>1244+ <Button variant="outline" onClick={() => { setStep("input"); setSourceType("lake"); setError(null) }}>
1239 Back1245 Back
1240 </Button>1246 </Button>
1241 )}1247 )}
1242- {step === "cannbay" && cannbayParadigm != null && (1248+ {step === "lake" && lakeParadigm != null && (
1243 <>1249 <>
1244- {cannbayParadigms.length > 0 ? (1250+ {lakeParadigms.length > 0 ? (
1245- <Button variant="outline" onClick={() => { setCannbayParadigm(null); setCannbayFilter(""); setCannbaySelected(new Set()); }}>1251+ <Button variant="outline" onClick={() => { setLakeParadigm(null); setLakeFilter(""); setLakeSelected(new Set()); }}>
1246 范式选择1252 范式选择
1247 </Button>1253 </Button>
1248 ) : (1254 ) : (
1249- <Button variant="outline" onClick={() => { setStep("input"); setSourceType("cannbay"); setError(null) }}>1255+ <Button variant="outline" onClick={() => { setStep("input"); setSourceType("lake"); setError(null) }}>
1250 Back1256 Back
1251 </Button>1257 </Button>
1252 )}1258 )}
1253- <Button onClick={handleImportCannbay} disabled={cannbaySelected.size === 0}>1259+ <Button onClick={handleImportLake} disabled={lakeSelected.size === 0}>
1254- Import {cannbaySelected.size > 0 ? `${cannbaySelected.size} Selected` : ""}1260+ Import {lakeSelected.size > 0 ? `${lakeSelected.size} Selected` : ""}
1255 </Button>1261 </Button>
1256 </>1262 </>
1257 )}1263 )}
@@ -1261,7 +1267,7 @@ export function LocalFileImport() {
1261 </DialogFooter>1267 </DialogFooter>
1262 </DialogContent>1268 </DialogContent>
1263 {/* 数据仓令牌引导弹窗(叠加在导入弹窗之上;保存成功自动收起) */}1269 {/* 数据仓令牌引导弹窗(叠加在导入弹窗之上;保存成功自动收起) */}
1264- <CannbayTokenDialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen} reason={tokenReason} />1270+ <LakeTokenDialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen} reason={tokenReason} onSaved={() => { if (tokenReason === 'unconfigured') { setTokenDialogOpen(false); handleOpenLake() } }} />
1265 </Dialog>1271 </Dialog>
1266 )1272 )
1267}1273}
@@ -24,7 +24,7 @@ import {
24import { Button } from '@/components/ui/button';24import { Button } from '@/components/ui/button';
25import { Badge } from '@/components/ui/badge';25import { Badge } from '@/components/ui/badge';
26import { Checkbox } from '@/components/ui/checkbox';26import { Checkbox } from '@/components/ui/checkbox';
27-import { UploadToCannbayDialog, type UploadCannbayTarget } from '@/components/UploadToCannbayDialog';27+import { UploadToLakeDialog, type UploadLakeTarget } from '@/components/UploadToLakeDialog';
28import { Input } from '@/components/ui/input';28import { Input } from '@/components/ui/input';
29import {29import {
30 Dialog,30 Dialog,
@@ -182,7 +182,7 @@ export function SessionList({ items: initialItems, total: initialTotal, page: in
182 const [uploadError, setUploadError] = useState<string | null>(null)182 const [uploadError, setUploadError] = useState<string | null>(null)
183 const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)183 const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
184 // 上传弹窗(共享组件):目标会话 + 开关;上传结果经回调回填行内状态图标184 // 上传弹窗(共享组件):目标会话 + 开关;上传结果经回调回填行内状态图标
185- const [uploadTarget, setUploadTarget] = useState<UploadCannbayTarget | null>(null)185+ const [uploadTarget, setUploadTarget] = useState<UploadLakeTarget | null>(null)
186 const [uploadOpen, setUploadOpen] = useState(false)186 const [uploadOpen, setUploadOpen] = useState(false)
187 187 
188 function openUploadDialog(item: SessionListItem) {188 function openUploadDialog(item: SessionListItem) {
@@ -443,7 +443,7 @@ export function SessionList({ items: initialItems, total: initialTotal, page: in
443 </DialogFooter>443 </DialogFooter>
444 </DialogContent>444 </DialogContent>
445 </Dialog>445 </Dialog>
446- <UploadToCannbayDialog446+ <UploadToLakeDialog
447 open={uploadOpen}447 open={uploadOpen}
448 onOpenChange={setUploadOpen}448 onOpenChange={setUploadOpen}
449 target={uploadTarget}449 target={uploadTarget}
Rpackages/insight/src/components/UploadToCannbayDialog.tsx→packages/insight/src/components/UploadToLakeDialog.tsx+47-42
@@ -14,7 +14,7 @@
14// 弹窗标题用 BAY_NAME(@/lib/branding)——context 双名发布时 Bay 显示名随品牌翻转。14// 弹窗标题用 BAY_NAME(@/lib/branding)——context 双名发布时 Bay 显示名随品牌翻转。
15 15 
16import { useEffect, useState } from 'react'16import { useEffect, useState } from 'react'
17-import { loadCannbayConfig, cannbayRemoteInput, isDefaultCannbayRemote } from '@/lib/cannbay-config'17+ 
18import { BAY_NAME } from '@/lib/branding'18import { BAY_NAME } from '@/lib/branding'
19import { Button } from '@/components/ui/button'19import { Button } from '@/components/ui/button'
20import { Checkbox } from '@/components/ui/checkbox'20import { Checkbox } from '@/components/ui/checkbox'
@@ -26,16 +26,16 @@ import {
26 DialogHeader,26 DialogHeader,
27 DialogTitle,27 DialogTitle,
28} from '@/components/ui/dialog'28} from '@/components/ui/dialog'
29-import { CannbayTokenDialog } from '@/components/CannbayTokenDialog'29+import { LakeTokenDialog } from '@/components/LakeTokenDialog'
30import {30import {
31 findStoredAuthEntry,31 findStoredAuthEntry,
32 isCredentialErrorCode,32 isCredentialErrorCode,
33- resolveRemoteAddress,33+ serverLakeConfigStatus,
34- type CannbayTokenReason,34+ type LakeTokenReason,
35 type StoredRemoteInfo,35 type StoredRemoteInfo,
36-} from '@/lib/cannbay-token-prompt'36+} from '@/lib/lake-token-prompt'
37 37 
38-export interface UploadCannbayTarget {38+export interface UploadLakeTarget {
39 taskId: string39 taskId: string
40 query?: string | null40 query?: string | null
41 framework?: string | null41 framework?: string | null
@@ -46,10 +46,10 @@ export interface UploadCannbayTarget {
46 user?: string | null46 user?: string | null
47}47}
48 48 
49-interface UploadToCannbayDialogProps {49+interface UploadToLakeDialogProps {
50 open: boolean50 open: boolean
51 onOpenChange: (open: boolean) => void51 onOpenChange: (open: boolean) => void
52- target: UploadCannbayTarget | null52+ target: UploadLakeTarget | null
53 onUploadStart?: () => void53 onUploadStart?: () => void
54 onResult?: (result: { ok: boolean; message?: string }) => void54 onResult?: (result: { ok: boolean; message?: string }) => void
55}55}
@@ -70,7 +70,7 @@ function formatTime(iso: string | null | undefined): string {
70 70 
71// 提交信息组装:提交人(独立输入框唯一事实源)+ 内容描述(选填,空则省略71// 提交信息组装:提交人(独立输入框唯一事实源)+ 内容描述(选填,空则省略
72// 该行)+ 溯源上下文(日志路径/备注,自动附加)。后端网关从首行「提交人:」提取。72// 该行)+ 溯源上下文(日志路径/备注,自动附加)。后端网关从首行「提交人:」提取。
73-function buildUploadDescription(target: UploadCannbayTarget, submitter: string, contentDesc: string): string {73+function buildUploadDescription(target: UploadLakeTarget, submitter: string, contentDesc: string): string {
74 const model = target.model ?? ''74 const model = target.model ?? ''
75 const timeRange = `${formatTime(target.startTime)} → ${formatTime(target.endTime)}`75 const timeRange = `${formatTime(target.startTime)} → ${formatTime(target.endTime)}`
76 const framework = target.framework ?? ''76 const framework = target.framework ?? ''
@@ -82,12 +82,7 @@ function buildUploadDescription(target: UploadCannbayTarget, submitter: string,
82${descLine}${context}`82${descLine}${context}`
83}83}
84 84 
85-// 范式目录下拉仅对默认仓启用(自定义数据湖地址走 directory 机制,网关同样拦截)85+export function UploadToLakeDialog({ open, onOpenChange, target, onUploadStart, onResult }: UploadToLakeDialogProps) {
86-function isDefaultRemote(): boolean {
87- return isDefaultCannbayRemote(loadCannbayConfig())
88-}
89- 
90-export function UploadToCannbayDialog({ open, onOpenChange, target, onUploadStart, onResult }: UploadToCannbayDialogProps) {
91 const [uploadContentDesc, setUploadContentDesc] = useState('')86 const [uploadContentDesc, setUploadContentDesc] = useState('')
92 const [uploadSubmitter, setUploadSubmitter] = useState('')87 const [uploadSubmitter, setUploadSubmitter] = useState('')
93 const [uploadSubmitterTouched, setUploadSubmitterTouched] = useState(false)88 const [uploadSubmitterTouched, setUploadSubmitterTouched] = useState(false)
@@ -104,13 +99,12 @@ export function UploadToCannbayDialog({ open, onOpenChange, target, onUploadStar
104 // 凭据引导弹窗:默认仓上传必然需要凭据——打开即检(未配置主动弹出),99 // 凭据引导弹窗:默认仓上传必然需要凭据——打开即检(未配置主动弹出),
105 // 凭据类失败(凭据无效/无写权限)反应弹出;保存一次全机生效100 // 凭据类失败(凭据无效/无写权限)反应弹出;保存一次全机生效
106 const [tokenDialogOpen, setTokenDialogOpen] = useState(false)101 const [tokenDialogOpen, setTokenDialogOpen] = useState(false)
107- const [tokenReason, setTokenReason] = useState<CannbayTokenReason>('no-credentials')102+ const [tokenReason, setTokenReason] = useState<LakeTokenReason>('no-credentials')
108 103 
109 // 打开即重置 + 预填(描述=首问,提交人=会话用户)——render-phase prop 变化104 // 打开即重置 + 预填(描述=首问,提交人=会话用户)——render-phase prop 变化
110 // 模式(https://react.dev/learn/you-might-not-need-an-effect),替代在105 // 模式(https://react.dev/learn/you-might-not-need-an-effect),替代在
111 // effect 里同步 setState(react-hooks 规则禁止,会触发级联渲染)106 // effect 里同步 setState(react-hooks 规则禁止,会触发级联渲染)
112 const [wasOpen, setWasOpen] = useState(false)107 const [wasOpen, setWasOpen] = useState(false)
113- const defaultRemote = isDefaultRemote()
114 if (open !== wasOpen) {108 if (open !== wasOpen) {
115 setWasOpen(open)109 setWasOpen(open)
116 if (open && target) {110 if (open && target) {
@@ -122,57 +116,71 @@ export function UploadToCannbayDialog({ open, onOpenChange, target, onUploadStar
122 setUploadOpGen('')116 setUploadOpGen('')
123 setParadigmDirs([])117 setParadigmDirs([])
124 setParadigmError(null)118 setParadigmError(null)
125- setParadigmLoading(defaultRemote)119+ setParadigmLoading(true)
126 }120 }
127 }121 }
128 122 
129- // 打开且默认仓 → 拉范式目录(异步回调内 setState,非同步)123+ // 打开 → 拉范式目录 + 会话清单(单源:服务端配置远端;根级会话目录不进注册表)
130 useEffect(() => {124 useEffect(() => {
131- if (!open || !defaultRemote) return125+ if (!open) return
132 let active = true126 let active = true
133- fetch('/api/ingest/cannbay2', {127+ fetch('/api/ingest/lake', {
134 method: 'POST',128 method: 'POST',
135 headers: { 'Content-Type': 'application/json' },129 headers: { 'Content-Type': 'application/json' },
136- body: JSON.stringify({ action: 'dirs', ...cannbayRemoteInput(loadCannbayConfig()) }),130+ body: JSON.stringify({ action: 'list' }),
137 })131 })
138 .then(res => res.json().then(data => ({ ok: res.ok, data })))132 .then(res => res.json().then(data => ({ ok: res.ok, data })))
139 .then(({ ok, data }) => {133 .then(({ ok, data }) => {
140 if (!active) return134 if (!active) return
141 if (!ok) throw new Error(data.error ?? 'Failed to list paradigm dirs')135 if (!ok) throw new Error(data.error ?? 'Failed to list paradigm dirs')
142- const dirs = (data.dirs ?? []).filter((d: string) => d !== 'sessions')136+ // 全仓顶层目录即范式注册表(sessions/ 存量区特判已退役);根级
137+ // 会话目录(缺省上传位)按会话清单剔除
138+ const rootFolders = new Set(((data.sessions ?? []) as Array<{ sid: string; paradigm: string | null; recording?: string }>).filter(s => !s.paradigm).map(s => s.sid + (s.recording === 'native' ? '.native' : '')))
139+ const dirs = ((data.dirs ?? []) as string[]).filter(d => !rootFolders.has(d))
143 setParadigmDirs(dirs)140 setParadigmDirs(dirs)
144 if (dirs.length === 0) setParadigmError('数据仓暂无范式目录:请先在数据仓新建顶层目录')141 if (dirs.length === 0) setParadigmError('数据仓暂无范式目录:请先在数据仓新建顶层目录')
145 })142 })
146 .catch(() => { if (active) setParadigmError('范式目录列表加载失败,请重试') })143 .catch(() => { if (active) setParadigmError('范式目录列表加载失败,请重试') })
147 .finally(() => { if (active) setParadigmLoading(false) })144 .finally(() => { if (active) setParadigmLoading(false) })
148 return () => { active = false }145 return () => { active = false }
149- }, [open, defaultRemote])146+ }, [open])
150 147 
151- // 打开且默认仓 → 凭据预检:未配置凭据直接弹引导(免去填完表单才在148+ // 打开 → 凭据预检:服务端配置远端的已存凭据缺失直接弹引导(免去填完表单
152- // 提交时撞 no-credentials);已配置则静默149+ // 才在提交时撞 no-credentials);已配置则静默
153 useEffect(() => {150 useEffect(() => {
154- if (!open || !defaultRemote) return151+ if (!open) return
155 let active = true152 let active = true
156- const address = resolveRemoteAddress(loadCannbayConfig())153+ void (async () => {
157- fetch('/api/settings/cannbay-auth')154+ try {
158- .then(r => r.json())155+ const st = await serverLakeConfigStatus()
159- .then((data: { remotes?: StoredRemoteInfo[] }) => {
160 if (!active) return156 if (!active) return
161- if (!findStoredAuthEntry(data.remotes ?? [], address)) {157+ if (!st.configured) return // 地址预检在提交时拦(unconfigured 语境)
158+ const res = await fetch('/api/settings/lake-auth')
159+ const data = await res.json() as { remotes?: StoredRemoteInfo[] }
160+ if (!active) return
161+ if (!findStoredAuthEntry(data.remotes ?? [], st.address)) {
162 setTokenReason('no-credentials')162 setTokenReason('no-credentials')
163 setTokenDialogOpen(true)163 setTokenDialogOpen(true)
164 }164 }
165- })165+ } catch { /* 状态读取失败不阻塞上传弹窗 */ }
166- .catch(() => { /* 状态读取失败不阻塞上传弹窗 */ })166+ })()
167 return () => { active = false }167 return () => { active = false }
168- }, [open, defaultRemote])168+ }, [open])
169 169 
170 async function handleUpload() {170 async function handleUpload() {
171 if (!target) return171 if (!target) return
172+ // 未配置预检(与 LocalFileImport 同款):服务端 lake-config.json 未配 →
173+ // 弹引导;已配则请求不带 config(服务端单源解析)
174+ const server = await serverLakeConfigStatus()
175+ if (!server.configured) {
176+ setTokenReason('unconfigured')
177+ setTokenDialogOpen(true)
178+ return
179+ }
172 setUploading(true)180 setUploading(true)
173 onUploadStart?.()181 onUploadStart?.()
174 try {182 try {
175- const res = await fetch('/api/ingest/cannbay2', {183+ const res = await fetch('/api/ingest/lake', {
176 method: 'POST',184 method: 'POST',
177 headers: { 'Content-Type': 'application/json' },185 headers: { 'Content-Type': 'application/json' },
178 body: JSON.stringify({186 body: JSON.stringify({
@@ -183,7 +191,6 @@ export function UploadToCannbayDialog({ open, onOpenChange, target, onUploadStar
183 description: buildUploadDescription(target, uploadSubmitter, uploadContentDesc),191 description: buildUploadDescription(target, uploadSubmitter, uploadContentDesc),
184 ...(uploadCategory ? { category: uploadCategory, ...(uploadBaseline ? { baseline: true } : {}) } : {}),192 ...(uploadCategory ? { category: uploadCategory, ...(uploadBaseline ? { baseline: true } : {}) } : {}),
185 ...(uploadOpGen ? { opGen: uploadOpGen } : {}),193 ...(uploadOpGen ? { opGen: uploadOpGen } : {}),
186- ...cannbayRemoteInput(loadCannbayConfig()),
187 }),194 }),
188 })195 })
189 const data = await res.json()196 const data = await res.json()
@@ -211,8 +218,7 @@ export function UploadToCannbayDialog({ open, onOpenChange, target, onUploadStar
211 <DialogTitle>上传到 {BAY_NAME}</DialogTitle>218 <DialogTitle>上传到 {BAY_NAME}</DialogTitle>
212 <DialogDescription>会话经数据治理后上传至数据仓,可随时重新导入</DialogDescription>219 <DialogDescription>会话经数据治理后上传至数据仓,可随时重新导入</DialogDescription>
213 </DialogHeader>220 </DialogHeader>
214- {defaultRemote && (221+ <div className="space-y-1.5">
215- <div className="space-y-1.5">
216 <label className="text-sm font-medium">222 <label className="text-sm font-medium">
217 范式目录 <span className="text-red-500">*</span>223 范式目录 <span className="text-red-500">*</span>
218 </label>224 </label>
@@ -248,7 +254,6 @@ export function UploadToCannbayDialog({ open, onOpenChange, target, onUploadStar
248 </span>254 </span>
249 </label>255 </label>
250 </div>256 </div>
251- )}
252 <div className="space-y-1.5">257 <div className="space-y-1.5">
253 <label className="text-sm font-medium">258 <label className="text-sm font-medium">
254 提交人 <span className="text-red-500">*</span>259 提交人 <span className="text-red-500">*</span>
@@ -306,14 +311,14 @@ export function UploadToCannbayDialog({ open, onOpenChange, target, onUploadStar
306 variant="default"311 variant="default"
307 className="bg-blue-600 hover:bg-blue-700 text-white"312 className="bg-blue-600 hover:bg-blue-700 text-white"
308 onClick={handleUpload}313 onClick={handleUpload}
309- disabled={uploading || !uploadSubmitter.trim() || uploadOpGen === '' || (isDefaultRemote() && (!uploadCategory || paradigmDirs.length === 0))}314+ disabled={uploading || !uploadSubmitter.trim() || uploadOpGen === '' || (!uploadCategory || paradigmDirs.length === 0)}
310 >315 >
311 {uploading ? '上传中...' : '确认上传'}316 {uploading ? '上传中...' : '确认上传'}
312 </Button>317 </Button>
313 </DialogFooter>318 </DialogFooter>
314 </DialogContent>319 </DialogContent>
315 {/* 令牌引导弹窗(叠加在上传弹窗之上;保存成功自动收起,表单保留可重试) */}320 {/* 令牌引导弹窗(叠加在上传弹窗之上;保存成功自动收起,表单保留可重试) */}
316- <CannbayTokenDialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen} reason={tokenReason} />321+ <LakeTokenDialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen} reason={tokenReason} onSaved={() => { if (tokenReason === 'unconfigured') { setTokenDialogOpen(false); handleUpload() } }} />
317 </Dialog>322 </Dialog>
318 )323 )
319}324}
Mpackages/insight/src/lib/active-time-map.ts+1-1文件内容审核中,请稍后刷新重试
@@ -8,6 +8,7 @@
8 8 
9import fs from 'node:fs';9import fs from 'node:fs';
10import { listSubagentSessions, parseJsonlLines } from '@/lib/ingest/adapters/claude-jsonl';10import { listSubagentSessions, parseJsonlLines } from '@/lib/ingest/adapters/claude-jsonl';
11+import { readEnvelope } from '@/lib/shared/envelope';
11 12 
12/**13/**
13 * agent .md 的**会话内 system 注入恢复**(proxy 扩展层,server-only)。14 * agent .md 的**会话内 system 注入恢复**(proxy 扩展层,server-only)。
@@ -65,11 +66,8 @@ function extractSystemFromSubagentJsonl(filePath: string): string | null {
65 }66 }
66 for (const line of lines) {67 for (const line of lines) {
67 if (line.type !== 'assistant') continue;68 if (line.type !== 'assistant') continue;
68- const l = line as {69+ const l = line as { system?: unknown };
69- system?: unknown;70+ const sysAny = readEnvelope(line)?.data?.system ?? l.system;
70- x_cannbay?: { data?: { system?: unknown } };
71- };
72- const sysAny = l.x_cannbay?.data?.system ?? l.system;
73 if (typeof sysAny === 'string' && sysAny.trim()) return sysAny;71 if (typeof sysAny === 'string' && sysAny.trim()) return sysAny;
74 if (Array.isArray(sysAny)) {72 if (Array.isArray(sysAny)) {
75 // ContentBlock[] 形态:拼接 text 块73 // ContentBlock[] 形态:拼接 text 块
@@ -100,9 +98,8 @@ function readMetaAgentType(subFilePath: string): string | null {
100 try {98 try {
101 const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as {99 const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as {
102 agentType?: unknown;100 agentType?: unknown;
103- x_cannbay?: { data?: { agentType?: unknown } };
104 };101 };
105- const at = meta.x_cannbay?.data?.agentType ?? meta.agentType;102+ const at = readEnvelope(meta)?.data?.agentType ?? meta.agentType;
106 return typeof at === 'string' && at ? at : null;103 return typeof at === 'string' && at ? at : null;
107 } catch {104 } catch {
108 return null;105 return null;
@@ -15,11 +15,10 @@ const BRAND_CLI_ALIAS = "cbi"
15// Data format identifier — always "cannbot-insight" regardless of branding15// Data format identifier — always "cannbot-insight" regardless of branding
16const BRAND_SOURCE_TYPE = "cannbot-insight"16const BRAND_SOURCE_TYPE = "cannbot-insight"
17 17 
18-// 数据之湖显示名:CANNBot 品牌用 "CANNbay",其他品牌用 "contextBay"18+// 数据湖显示名:CANNBot 品牌用 "CANNbay",其他品牌用 "contextBay"
19const BAY_NAME = BRAND_SLUG === "cannbot-insight" ? "CANNbay" : "contextBay"19const BAY_NAME = BRAND_SLUG === "cannbot-insight" ? "CANNbay" : "contextBay"
20 20 
21-// viewer 页显示名(首页入口按钮 + viewer 标题):CANNBot 品牌用 "CANNBay Viewer",21+// viewer 页显示名(首页入口按钮 + viewer 标题):品牌配对(打包翻转规则见 pack-context)
22-// 其他品牌用 "contextBay Viewer"(打包规则表配对翻转,哨兵测试锁零泄漏)
23const BAY_VIEWER_NAME = BRAND_SLUG === "cannbot-insight" ? "CANNBay Viewer" : "contextBay Viewer"22const BAY_VIEWER_NAME = BRAND_SLUG === "cannbot-insight" ? "CANNBay Viewer" : "contextBay Viewer"
24 23 
25export { BRAND_NAME, BRAND_SLUG, BRAND_DESCRIPTION, BRAND_CONFIG_DIR_SUFFIX, BRAND_CLI_ALIAS, BRAND_SOURCE_TYPE, BAY_NAME, BAY_VIEWER_NAME }24export { BRAND_NAME, BRAND_SLUG, BRAND_DESCRIPTION, BRAND_CONFIG_DIR_SUFFIX, BRAND_CLI_ALIAS, BRAND_SOURCE_TYPE, BAY_NAME, BAY_VIEWER_NAME }
@@ -21,6 +21,7 @@ import {
21 type ClaudeJsonlLine,21 type ClaudeJsonlLine,
22 type ContentBlock,22 type ContentBlock,
23} from './claude-jsonl';23} from './claude-jsonl';
24+import { readEnvelope } from '@/lib/shared/envelope';
24import { classifyProxyCapture } from '../proxy-classify';25import { classifyProxyCapture } from '../proxy-classify';
25import {26import {
26 parseClaudeAvailableSkills,27 parseClaudeAvailableSkills,
@@ -123,7 +124,7 @@ function readFullContextUncached(filePath: string): FullContext | null {
123 let foundExt = false;124 let foundExt = false;
124 for (const line of lines) {125 for (const line of lines) {
125 if (foundExt || line.type !== 'assistant') continue;126 if (foundExt || line.type !== 'assistant') continue;
126- const xb = (line as { x_cannbay?: { data?: { system?: unknown; tools?: unknown } } }).x_cannbay;127+ const xb = readEnvelope(line);
127 const sysAny = xb?.data?.system ?? (line as { system?: unknown }).system;128 const sysAny = xb?.data?.system ?? (line as { system?: unknown }).system;
128 const toolsAny = xb?.data?.tools ?? (line as { tools?: unknown }).tools;129 const toolsAny = xb?.data?.tools ?? (line as { tools?: unknown }).tools;
129 if (Array.isArray(toolsAny) && toolsAny.length > 0) {130 if (Array.isArray(toolsAny) && toolsAny.length > 0) {
@@ -183,10 +184,10 @@ function readTurnRequestParamsUncached(filePath: string, turnIndex: number): Tur
183 if (l.type !== 'assistant') continue;184 if (l.type !== 'assistant') continue;
184 assistantSeen++;185 assistantSeen++;
185 if (assistantSeen !== turnIndex) continue;186 if (assistantSeen !== turnIndex) continue;
186- const xb = (l as { x_cannbay?: { schema?: string; version?: number; data?: { requestParams?: { temperature?: number; maxTokens?: number; model?: string | null } } } }).x_cannbay;187+ const xb = readEnvelope(l);
187 // version 门禁(spec §6):只认 cc-wire-round v1188 // version 门禁(spec §6):只认 cc-wire-round v1
188 if (!xb || xb.schema !== 'cc-wire-round' || xb.version !== 1) continue;189 if (!xb || xb.schema !== 'cc-wire-round' || xb.version !== 1) continue;
189- const rp = xb.data?.requestParams;190+ const rp = xb.data?.requestParams as { temperature?: number; maxTokens?: number; model?: string | null } | undefined;
190 return {191 return {
191 temperature: rp?.temperature ?? null,192 temperature: rp?.temperature ?? null,
192 maxTokens: rp?.maxTokens ?? null,193 maxTokens: rp?.maxTokens ?? null,
@@ -213,7 +214,7 @@ export function readAvailableSkills(
213 }214 }
214 const candidates: string[] = [];215 const candidates: string[] = [];
215 for (const line of lines) {216 for (const line of lines) {
216- const xb = (line as { x_cannbay?: { data?: { system?: unknown } } }).x_cannbay;217+ const xb = readEnvelope(line);
217 const sysAny = xb?.data?.system ?? (line as { system?: unknown }).system;218 const sysAny = xb?.data?.system ?? (line as { system?: unknown }).system;
218 if (typeof sysAny === 'string' && sysAny) candidates.push(sysAny);219 if (typeof sysAny === 'string' && sysAny) candidates.push(sysAny);
219 const msg = line.message;220 const msg = line.message;
@@ -9,6 +9,7 @@
9import fs from 'node:fs';9import fs from 'node:fs';
10import path from 'node:path';10import path from 'node:path';
11import { readCaptureBuffer, readCaptureText as readArchivedText } from '@/lib/compression';11import { readCaptureBuffer, readCaptureText as readArchivedText } from '@/lib/compression';
12+import { readEnvelope } from '@/lib/shared/envelope';
12import type { SessionListItem, RawInteraction, ToolCallInfo, TokenUsage } from '../../shared/types';13import type { SessionListItem, RawInteraction, ToolCallInfo, TokenUsage } from '../../shared/types';
13 14 
14// Shared scan helpers (claude-format concepts, also reused by the proxy15// Shared scan helpers (claude-format concepts, also reused by the proxy
@@ -67,12 +68,11 @@ export interface ClaudeJsonlLine {
67 ttftMs?: number;68 ttftMs?: number;
68 deduped?: boolean;69 deduped?: boolean;
69 originalChars?: number;70 originalChars?: number;
70- // x_cannbay 声明式扩展(docs/cannbay-schema-spec.md):信封外一切扩展的71+ // x_lake/x_cannbay 信封字段:读方一律经 shared/envelope readEnvelope 双名
71- // 权威源,双轨期与上方 legacy 顶层字段并存 —— 读方一律 x_cannbay 优先。72+ // 解析(键名与优先级单一真身),此处不再逐字段声明
72- x_cannbay?: XCannbay;
73}73}
74 74 
75-export interface XCannbay {75+export interface XLake {
76 schema: string;76 schema: string;
77 version: number;77 version: number;
78 data: {78 data: {
@@ -110,7 +110,7 @@ const TURN_PAYLOAD_VERSIONS: Record<string, number[]> = {
110 'cc-wire-round': [1],110 'cc-wire-round': [1],
111 'cc-db-turn': [1],111 'cc-db-turn': [1],
112};112};
113-export function isTurnPayload(xb: XCannbay | undefined): boolean {113+export function isTurnPayload(xb: XLake | undefined): boolean {
114 if (!xb) return false;114 if (!xb) return false;
115 const vs = TURN_PAYLOAD_VERSIONS[xb.schema];115 const vs = TURN_PAYLOAD_VERSIONS[xb.schema];
116 return !!vs && typeof xb.version === 'number' && vs.includes(xb.version);116 return !!vs && typeof xb.version === 'number' && vs.includes(xb.version);
@@ -122,7 +122,7 @@ const META_PAYLOAD_VERSIONS: Record<string, number[]> = {
122 'cc-subagent-meta': [1],122 'cc-subagent-meta': [1],
123 'cc-session-meta': [1],123 'cc-session-meta': [1],
124};124};
125-export function isMetaPayload(xb: XCannbay | undefined, schema: string): boolean {125+export function isMetaPayload(xb: XLake | undefined, schema: string): boolean {
126 if (!xb || xb.schema !== schema) return false;126 if (!xb || xb.schema !== schema) return false;
127 const vs = META_PAYLOAD_VERSIONS[schema];127 const vs = META_PAYLOAD_VERSIONS[schema];
128 return !!vs && typeof xb.version === 'number' && vs.includes(xb.version);128 return !!vs && typeof xb.version === 'number' && vs.includes(xb.version);
@@ -538,8 +538,8 @@ export function collectSubagentToolUseMappings(dirPath: string, sessionId: strin
538 try {538 try {
539 if (fs.existsSync(metaPath)) {539 if (fs.existsSync(metaPath)) {
540 const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));540 const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
541- // x_cannbay.data 优先(cc-subagent-meta,version 门禁守过),顶层旧字段 fallback(legacy 文件)541+ // 双名信封优先(cc-subagent-meta,version 门禁守过),顶层旧字段 fallback(legacy 文件)
542- const xb = meta?.x_cannbay;542+ const xb = readEnvelope(meta) as XLake | undefined;
543 const data = xb && isMetaPayload(xb, 'cc-subagent-meta') ? xb.data : null;543 const data = xb && isMetaPayload(xb, 'cc-subagent-meta') ? xb.data : null;
544 const toolUseId = data?.toolUseId ?? meta.toolUseId;544 const toolUseId = data?.toolUseId ?? meta.toolUseId;
545 if (toolUseId) {545 if (toolUseId) {
@@ -557,7 +557,7 @@ function isValidISO(timestamp: string): boolean {
557 557 
558function extractVersionFromLines(lines: ClaudeJsonlLine[]): string | null {558function extractVersionFromLines(lines: ClaudeJsonlLine[]): string | null {
559 for (const line of lines) {559 for (const line of lines) {
560- const xv = line.x_cannbay?.data?.ccVersion;560+ const xv = (readEnvelope(line) as XLake | undefined)?.data?.ccVersion;
561 if (xv) return xv;561 if (xv) return xv;
562 if (line.version) return line.version;562 if (line.version) return line.version;
563 }563 }
@@ -742,14 +742,15 @@ export function buildAssistantInteraction(
742 // turn-split 写 Turn.finishReason,走标准管线进 DB(非扩展层覆盖)。742 // turn-split 写 Turn.finishReason,走标准管线进 DB(非扩展层覆盖)。
743 if (al.stopReason) mergedFinishReason = al.stopReason;743 if (al.stopReason) mergedFinishReason = al.stopReason;
744 if (al.duration_ms != null) explicitDurationMs = al.duration_ms;744 if (al.duration_ms != null) explicitDurationMs = al.duration_ms;
745- // 生成文件标记:cpx 捕获(*-proxy)与 cannbay2 DB 导出(insight-export)。745+ // 生成文件标记:cpx 捕获(*-proxy)与 lake DB 导出(insight-export)。
746 // 两者都声明 duration_ms 承载真实 latency(completed 置 undefined 让746 // 两者都声明 duration_ms 承载真实 latency(completed 置 undefined 让
747 // turn-split fallback 到 interaction.latency);仅 *-proxy 计入747 // turn-split fallback 到 interaction.latency);仅 *-proxy 计入
748 // Session.version(proxySourceOf 只认 -proxy 后缀,导出件不误标代理徽标)。748 // Session.version(proxySourceOf 只认 -proxy 后缀,导出件不误标代理徽标)。
749 if (!isProxy && typeof al.source === 'string'749 if (!isProxy && typeof al.source === 'string'
750 && (al.source.endsWith('-proxy') || al.source === 'insight-export')) isProxy = true;750 && (al.source.endsWith('-proxy') || al.source === 'insight-export')) isProxy = true;
751- // x_cannbay 声明式通道(优先):同一信息的权威源 + legacy 没有的 reasoningTokens751+ // 声明式信封通道(优先):同一信息的权威源 + legacy 没有的 reasoningTokens
752- const xbTurn = isTurnPayload(al.x_cannbay) ? al.x_cannbay!.data : null;752+ const xbRaw = readEnvelope(al) as XLake | undefined;
753+ const xbTurn = isTurnPayload(xbRaw) ? xbRaw!.data : null;
753 if (xbTurn) {754 if (xbTurn) {
754 isProxy = true;755 isProxy = true;
755 if (xbTurn.stopReason != null) mergedFinishReason = xbTurn.stopReason;756 if (xbTurn.stopReason != null) mergedFinishReason = xbTurn.stopReason;
Mpackages/insight/src/lib/ingest/data-service.ts+2-2文件内容审核中,请稍后刷新重试
@@ -18,10 +18,11 @@
18// Gen6 + cannbot(opencode 系 CANN fork,openai 协议):source:"cannbot-proxy"、18// Gen6 + cannbot(opencode 系 CANN fork,openai 协议):source:"cannbot-proxy"、
19// meta.framework:"cannbot"(proxy/CANNBOT-DESIGN.md)19// meta.framework:"cannbot"(proxy/CANNBOT-DESIGN.md)
20//20//
21-// 信号优先级(cannbay-schema-spec §6 双轨:声明式权威 > legacy 行级):21+// 信号优先级(lake-schema-spec §6 双轨:声明式权威 > legacy 行级):
22// meta.json cc-session-meta(文件级) > 行级 source 标记 > wire 指纹兜底22// meta.json cc-session-meta(文件级) > 行级 source 标记 > wire 指纹兜底
23import fs from 'node:fs';23import fs from 'node:fs';
24import { parseJsonlLines, extractSystemText, type ContentBlock } from './adapters/claude-jsonl';24import { parseJsonlLines, extractSystemText, type ContentBlock } from './adapters/claude-jsonl';
25+import { readEnvelope } from '@/lib/shared/envelope';
25 26 
26export type ProxyAgentFramework = 'opencode' | 'claude-code' | 'codex' | 'cannbot' | null;27export type ProxyAgentFramework = 'opencode' | 'claude-code' | 'codex' | 'cannbot' | null;
27 28 
@@ -68,9 +69,9 @@ export function proxyRecordingFrameworkFromVersion(version: string | null | unde
68}69}
69 70 
70/** meta.json cc-session-meta 声明的内容(producer=cpx 时 framework/ccVersion 有效)。71/** meta.json cc-session-meta 声明的内容(producer=cpx 时 framework/ccVersion 有效)。
71- * 全部 meta.json 消费方(分类器 / 导入 ccVersion / cannbay2 重导入 patch)共用,72+ * 全部 meta.json 消费方(分类器 / 导入 ccVersion / lake 重导入 patch)共用,
72 * 不再各自内联 JSON 解析。governed:cpx 压缩时打的"字节流已经落盘咽喉73 * 不再各自内联 JSON 解析。governed:cpx 压缩时打的"字节流已经落盘咽喉
73- * redactor 全量清洗"标记(spec §4.4)——cannbay2 上传直传快路径的信任凭证。 */74+ * redactor 全量清洗"标记(spec §4.4)——lake 上传直传快路径的信任凭证。 */
74export interface CcSessionMeta {75export interface CcSessionMeta {
75 producer: string | null;76 producer: string | null;
76 framework: string | null;77 framework: string | null;
@@ -83,10 +84,8 @@ export function readCcSessionMeta(jsonlPath: string): CcSessionMeta | null {
83 const metaPath = jsonlPath.replace(/\.jsonl(\.7z|\.gz)?$/, '.meta.json');84 const metaPath = jsonlPath.replace(/\.jsonl(\.7z|\.gz)?$/, '.meta.json');
84 try {85 try {
85 if (!fs.existsSync(metaPath)) return null;86 if (!fs.existsSync(metaPath)) return null;
86- const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8')) as {87+ const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
87- x_cannbay?: { schema?: string; version?: number; data?: { producer?: string; framework?: string; ccVersion?: string; governed?: boolean } };88+ const xb = readEnvelope(raw) as { schema: string; version: number; data?: { producer?: string; framework?: string; ccVersion?: string; governed?: boolean } } | undefined;
88- };
89- const xb = raw.x_cannbay;
90 if (!xb || xb.schema !== 'cc-session-meta' || xb.version !== 1) return null;89 if (!xb || xb.schema !== 'cc-session-meta' || xb.version !== 1) return null;
91 return {90 return {
92 producer: xb.data?.producer ?? null,91 producer: xb.data?.producer ?? null,
@@ -117,14 +116,14 @@ export function classifyProxyCapture(jsonlPath: string): ProxyClassification {
117 const lines = parseJsonlLines(jsonlPath);116 const lines = parseJsonlLines(jsonlPath);
118 let lineMarker: string | null = null;117 let lineMarker: string | null = null;
119 let hasWireFingerprint = false;118 let hasWireFingerprint = false;
120- let hasXCannbay = false;119+ let hasXLake = false;
121 let systemText: string | null = null;120 let systemText: string | null = null;
122 121 
123 for (const l of lines) {122 for (const l of lines) {
124 const s = (l as { source?: unknown }).source;123 const s = (l as { source?: unknown }).source;
125 if (lineMarker == null && typeof s === 'string' && s.endsWith('-proxy')) lineMarker = s;124 if (lineMarker == null && typeof s === 'string' && s.endsWith('-proxy')) lineMarker = s;
126- const xb = (l as { x_cannbay?: { data?: { system?: unknown } } }).x_cannbay;125+ const xb = readEnvelope(l);
127- if (xb != null) hasXCannbay = true;126+ if (xb != null) hasXLake = true;
128 const sys = xb?.data?.system ?? (l as { system?: unknown }).system;127 const sys = xb?.data?.system ?? (l as { system?: unknown }).system;
129 if (sys != null && (typeof sys === 'string' ? sys.length > 0 : Array.isArray(sys))) {128 if (sys != null && (typeof sys === 'string' ? sys.length > 0 : Array.isArray(sys))) {
130 hasWireFingerprint = true;129 hasWireFingerprint = true;
@@ -135,9 +134,9 @@ export function classifyProxyCapture(jsonlPath: string): ProxyClassification {
135 134 
136 // 2. 行级 source 标记 —— 需 wire 证据交叉校验:真实 cpx 捕获(任意代际)135 // 2. 行级 source 标记 —— 需 wire 证据交叉校验:真实 cpx 捕获(任意代际)
137 // 至少带 system/tools wire 字段或 x_cannbay 声明。历史上 DB 导出件曾错误136 // 至少带 system/tools wire 字段或 x_cannbay 声明。历史上 DB 导出件曾错误
138- // 标 source:'claude-proxy'(a25ebdfd 修复前上传的污染件仍留在 cannbay 仓),137+ // 标 source:'claude-proxy'(a25ebdfd 修复前上传的污染件仍留在 lake 仓),
139 // 只有裸标记无任何 wire 数据 → 判为导出件,不算 proxy 捕获。138 // 只有裸标记无任何 wire 数据 → 判为导出件,不算 proxy 捕获。
140- if (lineMarker && (hasWireFingerprint || hasXCannbay)) {139+ if (lineMarker && (hasWireFingerprint || hasXLake)) {
141 return {140 return {
142 isProxy: true,141 isProxy: true,
143 marker: lineMarker,142 marker: lineMarker,
@@ -145,7 +144,7 @@ export function classifyProxyCapture(jsonlPath: string): ProxyClassification {
145 via: 'source-line',144 via: 'source-line',
146 };145 };
147 }146 }
148- if (lineMarker && !hasWireFingerprint && !hasXCannbay) {147+ if (lineMarker && !hasWireFingerprint && !hasXLake) {
149 // 污染导出件降级为 insight-export 语义(不标 proxy 徽标,不触发覆盖度)148 // 污染导出件降级为 insight-export 语义(不标 proxy 徽标,不触发覆盖度)
150 return { isProxy: false, marker: null, framework: null, via: null };149 return { isProxy: false, marker: null, framework: null, via: null };
151 }150 }
Rpackages/insight/src/lib/cannbay-config.ts→packages/insight/src/lib/lake-config.ts+33-80
@@ -8,17 +8,22 @@
8 8 
9import { BRAND_SLUG } from "@/lib/branding"9import { BRAND_SLUG } from "@/lib/branding"
10 10 
11-export const CANNBAY_CONFIG_STORAGE_KEY = `${BRAND_SLUG}-cannbay-config`11+export const LAKE_CONFIG_STORAGE_KEY = `${BRAND_SLUG}-lake-config`
12 12 
13-export type CannbayAuthMode = "none" | "password" | "token"13+// 上一版 saveCannbayConfig(cannbay-config.ts,平台化改名前)写入的存量键:
14+// 新键 miss 时回落读(升级用户表单预填/凭据匹配键的现实来源——当前代码
15+// 不再写 localStorage,升级机器上只有这个键),清除时一并移除防残留复活
16+const LEGACY_LAKE_CONFIG_STORAGE_KEY = `${BRAND_SLUG}-cannbay-config`
14 17 
15-// 地址输入框默认值;等于该值时请求不带地址 → 后端 env / 内置默认接管。18+export type LakeAuthMode = "none" | "password" | "token"
16-// 默认地址为占位示例;实际部署通过 env / 设置页配置真实数据仓
17-export const DEFAULT_CANNBAY_ADDRESS = "https://example.com/cannbay-lake.git"
18 19 
19-export interface CannbayRemoteInput {20+// 无占位默认地址:空串 = 未自定义 → 请求不带地址,后端 env 接管;后端也未
21+// 配置则 fail-fast 指引(lib/lake/remote.ts resolveLakeRemote)——绝不静默
22+// 指向示例地址
23+ 
24+export interface LakeRemoteInput {
20 address?: string25 address?: string
21- authMode?: CannbayAuthMode26+ authMode?: LakeAuthMode
22 account?: string27 account?: string
23 password?: string28 password?: string
24 token?: string29 token?: string
@@ -28,9 +33,9 @@ export interface CannbayRemoteInput {
28 lfs?: boolean33 lfs?: boolean
29}34}
30 35 
31-export interface CannbayConfig extends CannbayRemoteInput {36+export interface LakeConfig extends LakeRemoteInput {
32 address: string37 address: string
33- authMode: CannbayAuthMode38+ authMode: LakeAuthMode
34 account: string39 account: string
35 password: string40 password: string
36 token: string41 token: string
@@ -38,16 +43,15 @@ export interface CannbayConfig extends CannbayRemoteInput {
38 directory: string43 directory: string
39}44}
40 45 
41-export function migrateCannbayConfig(raw: unknown): CannbayConfig | null {46+export function migrateLakeConfig(raw: unknown): LakeConfig | null {
42 if (!raw || typeof raw !== "object") return null47 if (!raw || typeof raw !== "object") return null
43 const r = raw as Record<string, unknown>48 const r = raw as Record<string, unknown>
44 const str = (v: unknown): string => (typeof v === "string" ? v : "")49 const str = (v: unknown): string => (typeof v === "string" ? v : "")
45- let address = str(r.address).trim()50+ // 空地址 = 未自定义(后端 env 接管);旧版预设形状已随平台化退役
46- // 旧版预设形状(default/trace_corpus)与空地址一律回落默认地址51+ const address = str(r.address).trim()
47- if (!address) address = DEFAULT_CANNBAY_ADDRESS52+ const validMode = (v: string): LakeAuthMode | null =>
48- const validMode = (v: string): CannbayAuthMode | null =>53+ v === "none" || v === "password" || v === "token" ? (v as LakeAuthMode) : null
49- v === "none" || v === "password" || v === "token" ? (v as CannbayAuthMode) : null54+ const authMode: LakeAuthMode =
50- const authMode: CannbayAuthMode =
51 validMode(str(r.authMode))55 validMode(str(r.authMode))
52 ?? (r.authMode === undefined ? (str(r.account) || str(r.password) ? "password" : "none") : "none")56 ?? (r.authMode === undefined ? (str(r.account) || str(r.password) ? "password" : "none") : "none")
53 return {57 return {
@@ -61,41 +65,28 @@ export function migrateCannbayConfig(raw: unknown): CannbayConfig | null {
61 }65 }
62}66}
63 67 
64-export function loadCannbayConfig(): CannbayConfig | null {68+export function loadLakeConfig(): LakeConfig | null {
65 if (typeof window === "undefined") return null69 if (typeof window === "undefined") return null
66 try {70 try {
67- const raw = localStorage.getItem(CANNBAY_CONFIG_STORAGE_KEY)71+ const raw = localStorage.getItem(LAKE_CONFIG_STORAGE_KEY) ?? localStorage.getItem(LEGACY_LAKE_CONFIG_STORAGE_KEY)
68 if (!raw) return null72 if (!raw) return null
69- return migrateCannbayConfig(JSON.parse(raw))73+ return migrateLakeConfig(JSON.parse(raw))
70 } catch {74 } catch {
71 return null75 return null
72 }76 }
73}77}
74 78 
75-// 是否默认仓(无自定义地址):只有默认仓启用范式目录多根模式(上传必选79+// 默认仓概念已退役(二合一彻底统一):地址必须由用户显式配置,
76-// category、导入按范式浏览);自定义数据湖远端维持 directory 单根机制,80+// 范式目录多根模式恒开(全仓顶层目录即注册表)。directory 仅作上传缺省目录
77-// 后端 resolveCannbay2Remote 同款判定(hasAddress → paradigmRoots=false)
78-export function isDefaultCannbayRemote(config: CannbayConfig | null): boolean {
79- return !config || !config.address.trim() || config.address.trim() === DEFAULT_CANNBAY_ADDRESS
80-}
81 81 
82-// localStorage 只存 UI 偏好(地址/认证方式/分支/目录)——凭据走服务端存储82+// 单源架构:远端偏好(地址/分支/目录)的唯一存储是服务端 lake-config.json
83-// (cannbay-auth.json,设置页保存时落盘,cpx 与 insight 同机共享)。旧版83+// (设置页保存/清除,全机生效)。localStorage 仅作 legacy 预填源(loadLakeConfig
84-// 落在本地的凭据字段不再持久化(migrateCannbayConfig 仍读旧值供表单预填,84+// 供设置页把存量值带进表单——新键 miss 回落旧键 cannbay-config,保存一次即
85-// 用户重新保存即完成迁移)85+// 迁移完成;clearLakeConfig 在保存/清除时清掉新旧两键防复活)
86-export function saveCannbayConfig(config: CannbayConfig): void {86+export function clearLakeConfig(): void {
87 if (typeof window === "undefined") return87 if (typeof window === "undefined") return
88- localStorage.setItem(CANNBAY_CONFIG_STORAGE_KEY, JSON.stringify({88+ localStorage.removeItem(LAKE_CONFIG_STORAGE_KEY)
89- address: config.address,89+ localStorage.removeItem(LEGACY_LAKE_CONFIG_STORAGE_KEY)
90- authMode: config.authMode,
91- branch: config.branch,
92- directory: config.directory,
93- }))
94-}
95- 
96-export function clearCannbayConfig(): void {
97- if (typeof window === "undefined") return
98- localStorage.removeItem(CANNBAY_CONFIG_STORAGE_KEY)
99}90}
100 91 
101// 保存时的服务端凭据操作决策(设置页 handleSave 消费):填了令牌/密码 →92// 保存时的服务端凭据操作决策(设置页 handleSave 消费):填了令牌/密码 →
@@ -105,47 +96,9 @@ export function clearCannbayConfig(): void {
105// no-credentials 引导重配)96// no-credentials 引导重配)
106export type CredentialAction = "save-token" | "save-password" | "clear" | "keep"97export type CredentialAction = "save-token" | "save-password" | "clear" | "keep"
107 98 
108-export function planCredentialAction(config: CannbayConfig): CredentialAction {99+export function planCredentialAction(config: LakeConfig): CredentialAction {
109 if (config.authMode === "token" && config.token.trim()) return "save-token"100 if (config.authMode === "token" && config.token.trim()) return "save-token"
110 if (config.authMode === "password" && config.account.trim() && config.password.trim()) return "save-password"101 if (config.authMode === "password" && config.account.trim() && config.password.trim()) return "save-password"
111 if (config.authMode === "none") return "clear"102 if (config.authMode === "none") return "clear"
112 return "keep"103 return "keep"
113}104}
114- 
115-// 请求体构造:默认不带凭据(服务端从 cannbay-auth.json 按地址解析——上传/
116-// 列表/导入统一凭据源);仅 test/branches 即时验证场景带表单凭据
117-// (includeCreds: true —— 测试的是用户正在输入的值,不是已存的值)
118-export function cannbayRemoteInput(
119- config: CannbayConfig | null,
120- opts?: { includeCreds?: boolean },
121-): { config?: CannbayRemoteInput } {
122- if (!config) return {}
123- const has = (v?: string): boolean => !!v?.trim()
124- const creds: CannbayRemoteInput =
125- config.authMode === 'token'
126- ? { authMode: 'token', token: config.token }
127- : config.authMode === 'password'
128- ? { authMode: 'password', account: config.account, password: config.password }
129- : { authMode: 'none' }
130- const anyCred = [creds.account, creds.password, creds.token].some(v => has(v))
131- // 分支默认 main:等于 main(或空)→ 不带分支,后端默认语义保留
132- const branch = config.branch.trim();
133- const isDefaultBranch = !branch || branch === 'main';
134- // 地址为默认值(或空)→ 不带地址:后端 env / 内置默认接管(含 env 缓存目录语义)
135- const isDefault = !has(config.address) || config.address.trim() === DEFAULT_CANNBAY_ADDRESS
136- const withCreds = opts?.includeCreds && anyCred ? creds : { authMode: 'none' as const }
137- if (isDefault) {
138- // 无可传信息(默认地址 + 默认分支 + 无目录 + 未带表单凭据)→ 完全省略
139- // config,后端 env/内置默认接管
140- if (isDefaultBranch && !has(config.directory) && !(opts?.includeCreds && anyCred)) return {}
141- return { config: { ...withCreds, ...(isDefaultBranch ? {} : { branch }), directory: config.directory } }
142- }
143- return {
144- config: {
145- address: config.address.trim(),
146- ...withCreds,
147- ...(isDefaultBranch ? {} : { branch }),
148- directory: config.directory,
149- },
150- }
151-}
Rpackages/insight/src/lib/cannbay-token-prompt.ts→packages/insight/src/lib/lake-token-prompt.ts+42-9
@@ -6,7 +6,7 @@
6// INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.6// INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
7// See LICENSE in the root of the software repository for the full text of the License.7// See LICENSE in the root of the software repository for the full text of the License.
8 8 
9-// 数据仓推送凭据引导配置(纯逻辑层,供 CannbayTokenDialog 与两个触发方共用;9+// 数据仓推送凭据引导配置(纯逻辑层,供 LakeTokenDialog 与两个触发方共用;
10// 组件组合由 typecheck + 手动验,同 skill-audit-view 模式):10// 组件组合由 typecheck + 手动验,同 skill-audit-view 模式):
11// resolveRemoteAddress 当前生效远端地址(localStorage 地址 > 默认仓)11// resolveRemoteAddress 当前生效远端地址(localStorage 地址 > 默认仓)
12// findStoredAuthEntry 服务端已存凭据匹配(与后端 normalizeAuthKey 同语义:12// findStoredAuthEntry 服务端已存凭据匹配(与后端 normalizeAuthKey 同语义:
@@ -14,12 +14,12 @@
14// isCredentialErrorCode 凭据类错误码判定(触发弹窗的依据)14// isCredentialErrorCode 凭据类错误码判定(触发弹窗的依据)
15// credentialPromptCopy 触发语境文案(未配置/令牌无效/无写权限)15// credentialPromptCopy 触发语境文案(未配置/令牌无效/无写权限)
16 16 
17-import { DEFAULT_CANNBAY_ADDRESS, loadCannbayConfig, type CannbayConfig } from '@/lib/cannbay-config'17+import { loadLakeConfig, type LakeConfig } from '@/lib/lake-config'
18import { BAY_NAME } from '@/lib/branding'18import { BAY_NAME } from '@/lib/branding'
19 19 
20-export type CannbayTokenReason = 'no-credentials' | 'auth-failed' | 'forbidden'20+export type LakeTokenReason = 'unconfigured' | 'no-credentials' | 'auth-failed' | 'forbidden'
21 21 
22-/** GET /api/settings/cannbay-auth 的远端条目(令牌只回脱敏尾段) */22+/** GET /api/settings/lake-auth 的远端条目(令牌只回脱敏尾段) */
23export interface StoredRemoteInfo {23export interface StoredRemoteInfo {
24 url: string24 url: string
25 username: string25 username: string
@@ -28,12 +28,13 @@ export interface StoredRemoteInfo {
28}28}
29 29 
30/** 当前生效远端地址:设置页地址(自定义远端)> 默认仓地址;去尾斜杠。 */30/** 当前生效远端地址:设置页地址(自定义远端)> 默认仓地址;去尾斜杠。 */
31-export function resolveRemoteAddress(config: CannbayConfig | null = loadCannbayConfig()): string {31+export function resolveRemoteAddress(config: LakeConfig | null = loadLakeConfig()): string {
32- const a = (config?.address ?? '').trim() || DEFAULT_CANNBAY_ADDRESS32+ // 默认仓退役:地址只从用户配置来(localStorage lake-config),env 不再回落
33+ const a = (config?.address ?? '').trim()
33 return a.replace(/\/+$/, '')34 return a.replace(/\/+$/, '')
34}35}
35 36 
36-/** URL 键归一(与后端 cannbay2/auth.ts normalizeAuthKey 同语义,客户端轻量版)。 */37+/** URL 键归一(与后端 lake/auth.ts normalizeAuthKey 同语义,客户端轻量版)。 */
37function normalizeAuthKeyClient(url: string): string {38function normalizeAuthKeyClient(url: string): string {
38 const credless = url.replace(/^(https?:\/\/)[^/@]+@/i, '$1').trim().replace(/\/+$/, '')39 const credless = url.replace(/^(https?:\/\/)[^/@]+@/i, '$1').trim().replace(/\/+$/, '')
39 try {40 try {
@@ -47,6 +48,31 @@ function normalizeAuthKeyClient(url: string): string {
47 }48 }
48}49}
49 50 
51+/** 未配置判定(Import 点数据仓源的预检——客户端零往返):设置页地址与
52+ * env 均空 = 未配置(与服务端 resolveLakeRemote fail-fast 同输入同结论)。
53+ * 部署机 env 已配时用户无感——弹窗只在真的需要输入时出现。 */
54+export function isUnconfiguredRemote(config: LakeConfig | null = loadLakeConfig()): boolean {
55+ return resolveRemoteAddress(config) === ''
56+}
57+ 
58+/** 服务端配置状态(lake-config.json——单源架构的唯一配置源,设置页保存
59+ * 落盘,全机生效)。未配置预检(Import/上传弹窗):未配 → 弹引导(保存后
60+ * 自动重试);已配 → 请求不带 config,服务端单源解析。 */
61+export async function serverLakeConfigStatus(): Promise<{ configured: boolean; address: string; branch: string }> {
62+ try {
63+ const res = await fetch('/api/settings/lake-config')
64+ if (!res.ok) return { configured: false, address: '', branch: 'main' }
65+ const data = await res.json() as { configured?: boolean; address?: string; branch?: string }
66+ return {
67+ configured: data.configured === true,
68+ address: (data.address ?? '').trim(),
69+ branch: (data.branch ?? '').trim() || 'main',
70+ }
71+ } catch {
72+ return { configured: false, address: '', branch: 'main' }
73+ }
74+}
75+ 
50/** 已存凭据匹配(.git 后缀双向互认——设置页手输地址与解析层规范地址可差一个后缀)。 */76/** 已存凭据匹配(.git 后缀双向互认——设置页手输地址与解析层规范地址可差一个后缀)。 */
51export function findStoredAuthEntry(remotes: StoredRemoteInfo[], address: string): StoredRemoteInfo | null {77export function findStoredAuthEntry(remotes: StoredRemoteInfo[], address: string): StoredRemoteInfo | null {
52 const key = normalizeAuthKeyClient(address)78 const key = normalizeAuthKeyClient(address)
@@ -60,13 +86,20 @@ export function findStoredAuthEntry(remotes: StoredRemoteInfo[], address: string
60 86 
61/** 凭据类错误码(路由结构化 code):命中即弹引导配置。quota/unknown 不弹——87/** 凭据类错误码(路由结构化 code):命中即弹引导配置。quota/unknown 不弹——
62 * 那不是换令牌能解决的问题。 */88 * 那不是换令牌能解决的问题。 */
63-export function isCredentialErrorCode(code: unknown): code is CannbayTokenReason {89+export function isCredentialErrorCode(code: unknown): code is LakeTokenReason {
64 return code === 'no-credentials' || code === 'auth-failed' || code === 'forbidden'90 return code === 'no-credentials' || code === 'auth-failed' || code === 'forbidden'
65}91}
66 92 
93+/** 'unconfigured' 不是服务端错误码(客户端预检判定),错误码映射不认它 */
94+ 
67/** 触发语境文案:title 进弹窗标题,description 进副标题。 */95/** 触发语境文案:title 进弹窗标题,description 进副标题。 */
68-export function credentialPromptCopy(reason: CannbayTokenReason): { title: string; description: string } {96+export function credentialPromptCopy(reason: LakeTokenReason): { title: string; description: string } {
69 switch (reason) {97 switch (reason) {
98+ case 'unconfigured':
99+ return {
100+ title: '连接数据仓',
101+ description: `尚未配置数据仓地址。填写远端地址与推送凭据即可连接(保存在本机,cpx 命令行同机共享;高级选项可在「设置 → 数据之湖」调整)。`,
102+ }
70 case 'auth-failed':103 case 'auth-failed':
71 return {104 return {
72 title: '令牌无效或已过期',105 title: '令牌无效或已过期',
Rpackages/insight/src/lib/cannbay2/auth.ts→packages/insight/src/lib/lake/auth.ts+38-34
@@ -7,22 +7,18 @@
7// See LICENSE in the root of the software repository for the full text of the License.7// See LICENSE in the root of the software repository for the full text of the License.
8 8 
9// Push-credential management (insight side): user-configured credentials9// Push-credential management (insight side): user-configured credentials
10-// stored in ~/.cannbot-insight/cannbay-auth.json (0600 + atomic writes).10+// stored in ~/.cannbot-insight/lake-auth.json (0600 + atomic writes;
11-// Controlled dual implementation with cpx cannbay/auth.ts — resolution-chain11+// 旧名 cannbay-auth.json 读兜底——存量凭据无感迁移,见 authFilePath 注释)。
12-// behavior is locked by cpx tests/ut/cannbay-parity.test.ts
13-// 锁定;cpx 与 insight 同机共读同一文件(设置页配一次,两侧上传通用)。
14//12//
15-// 凭据解析优先级(全部惰性读,调用时刻解析 env——历史实现在模块顶层读死13+// 凭据解析(单源架构):lake-auth.json 按远端 URL 键控的服务端存储
16-// CANNBAY2_PUSH_URL,运行中的服务无法热切换令牌):14+// (insight 设置页 / cbi auth 两个入口写同一份)。旧 env 凭据通道
17-// ① CANNBAY2_PUSH_URL(完整凭据 URL 覆写:CI/测试兼容通道;无凭据形态 =15+// (LAKE_PUSH_URL / LAKE_USERNAME + LAKE_TOKEN,私仓世界 CI 形态)已退役
18-// 匿名推该目标,不回落 ②③——显式 env 即完整意图)16+// ——CI/脚本直接写 lake-auth.json(文件即接口)。全空 → null;https 远端
19-// ② CANNBAY2_USERNAME + CANNBAY2_TOKEN(分离式 env 对,CI/脚本友好)17+// push 前由 requirePushCredentials fail-fast(指引配置入口,而不是让 git
20-// ③ cannbay-auth.json(按远端 URL 键控的服务端存储;insight 设置页 /18+// 在 401/终端提示上挂死)。
21-// cbi auth 两个入口写同一份)
22-// 全空 → null;https 远端 push 前由 requirePushCredentials fail-fast(指引
23-// 配置入口,而不是让 git 在 401/终端提示上挂死)。
24 19 
25import fs from 'node:fs';20import fs from 'node:fs';
21+import { PLATFORM_AUTH_FAILED_CODES, PLATFORM_FORBIDDEN_CODES } from './platform';
26import os from 'node:os';22import os from 'node:os';
27import path from 'node:path';23import path from 'node:path';
28 24 
@@ -44,8 +40,17 @@ export function parseUrlCreds(url: string): { credless: string; auth: PushAuth |
44 40 
45// 凭据存储文件:与 proxy 捕获目录同根(CANNBOT_PROXY_DIR 重定向优先——测试/41// 凭据存储文件:与 proxy 捕获目录同根(CANNBOT_PROXY_DIR 重定向优先——测试/
46// 多实例隔离;'.cannbot-insight' 引号字面量是 pack-context 品牌翻转点,与42// 多实例隔离;'.cannbot-insight' 引号字面量是 pack-context 品牌翻转点,与
47-// lfs-setup.ts 同款纪律)。cpx 与 insight 两侧同款解析 → 同机共享同一份凭据。43+// lfs-setup.ts 同款纪律)。读首选 lake-auth.json;旧名文件(历史版本产物)
44+// 读兜底——存量凭据无感迁移:读旧写新,首次保存即落新文件,旧文件原地保留
45+// (避免版本回滚丢凭据)。新文件存在即为权威(用户主动清空不复活旧凭据)。
48export function authFilePath(): string {46export function authFilePath(): string {
47+ return path.join(
48+ path.dirname(process.env.CANNBOT_PROXY_DIR ?? path.join(os.homedir(), '.cannbot-insight', 'proxy')),
49+ 'lake-auth.json',
50+ );
51+}
52+ 
53+function legacyAuthFilePath(): string {
49 return path.join(54 return path.join(
50 path.dirname(process.env.CANNBOT_PROXY_DIR ?? path.join(os.homedir(), '.cannbot-insight', 'proxy')),55 path.dirname(process.env.CANNBOT_PROXY_DIR ?? path.join(os.homedir(), '.cannbot-insight', 'proxy')),
51 'cannbay-auth.json',56 'cannbay-auth.json',
@@ -62,14 +67,22 @@ interface AuthFileShape {
62 remotes: Record<string, StoredEntry>;67 remotes: Record<string, StoredEntry>;
63}68}
64 69 
65-// 损坏/缺文件 → 空表(凭据未配置),绝不抛——解析链消费方按 null 走 fail-fast70+// 损坏/缺文件 → 空表(凭据未配置),绝不抛——解析链消费方按 null 走 fail-fast。
71+// 新名文件存在即权威;不存在则读旧名兜底(存量迁移,见 authFilePath 注释)
66function readAuthFile(): AuthFileShape {72function readAuthFile(): AuthFileShape {
67- try {73+ for (const file of [authFilePath(), legacyAuthFilePath()]) {
68- const raw: unknown = JSON.parse(fs.readFileSync(authFilePath(), 'utf8'));74+ try {
69- if (raw && typeof raw === 'object' && typeof (raw as { remotes?: unknown }).remotes === 'object') {75+ const raw: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
70- return { remotes: (raw as AuthFileShape).remotes };76+ if (raw && typeof raw === 'object' && typeof (raw as { remotes?: unknown }).remotes === 'object') {
77+ return { remotes: (raw as AuthFileShape).remotes };
78+ }
79+ } catch { /* 无/坏文件 → 试下一个候选 */ }
80+ if (file === authFilePath() && fs.existsSync(file)) {
81+ // 新名文件存在但内容非法/空 remotes 形状:以空表视作权威,不再读旧名
82+ // (用户清空操作不因旧文件残留而复活)
83+ return { remotes: {} };
71 }84 }
72- } catch { /* 无/坏文件 → 空表 */ }85+ }
73 return { remotes: {} };86 return { remotes: {} };
74}87}
75 88 
@@ -184,15 +197,6 @@ export function listStoredRemotes(): StoredRemoteInfo[] {
184 .filter(e => e.username || e.tokenMasked !== '****');197 .filter(e => e.username || e.tokenMasked !== '****');
185}198}
186 199 
187-export function resolvePushAuth(remoteUrl: string, env: NodeJS.ProcessEnv = process.env): PushAuth | null {
188- const pushOverride = env.CANNBAY2_PUSH_URL?.trim();
189- if (pushOverride) return parseUrlCreds(pushOverride).auth;
190- const u = env.CANNBAY2_USERNAME?.trim();
191- const t = env.CANNBAY2_TOKEN?.trim();
192- if (u && t) return { username: u, password: t };
193- return getStoredCredentials(remoteUrl);
194-}
195- 
196/** https 远端 push 前置检查:无凭据 fail-fast 并给出全部配置入口——绝不放行200/** https 远端 push 前置检查:无凭据 fail-fast 并给出全部配置入口——绝不放行
197 * 到 git push(匿名 https 推送只会在 401/终端用户名提示上挂死,错误形态对201 * 到 git push(匿名 https 推送只会在 401/终端用户名提示上挂死,错误形态对
198 * 用户毫无指引)。本地路径(file:// / 绝对路径)不检查:测试 bare 仓匿名推202 * 用户毫无指引)。本地路径(file:// / 绝对路径)不检查:测试 bare 仓匿名推
@@ -210,13 +214,13 @@ export class MissingPushCredentialsError extends Error {
210 '配置方式(任选其一,cpx 与 insight 同机共享同一份凭据):\n' +214 '配置方式(任选其一,cpx 与 insight 同机共享同一份凭据):\n' +
211 ' 1. insight Web UI「设置 → 数据之湖」填写推送凭据\n' +215 ' 1. insight Web UI「设置 → 数据之湖」填写推送凭据\n' +
212 ' 2. 命令行运行 cbi auth 交互式配置(insight CLI:npm 安装后 cbi 直接可用)\n' +216 ' 2. 命令行运行 cbi auth 交互式配置(insight CLI:npm 安装后 cbi 直接可用)\n' +
213- ' 3. 环境变量 CANNBAY2_USERNAME + CANNBAY2_TOKEN(CI/脚本场景)',217+ ' 3. 直接写 ~/.cannbot-insight/lake-auth.json(CI/脚本场景——文件即接口)',
214 );218 );
215 this.name = 'MissingPushCredentialsError';219 this.name = 'MissingPushCredentialsError';
216 }220 }
217}221}
218 222 
219-export type CannbayErrorCode = 'no-credentials' | 'auth-failed' | 'forbidden' | 'quota' | 'unknown';223+export type LakeErrorCode = 'no-credentials' | 'auth-failed' | 'forbidden' | 'quota' | 'unknown';
220 224 
221/** 上传错误分类(路由结构化 code / cpx CLI 引导文案共用):无凭据 > 配额 >225/** 上传错误分类(路由结构化 code / cpx CLI 引导文案共用):无凭据 > 配额 >
222 * 无推送权限 > 鉴权失败 > 未知。stderr 一并参与匹配——git 的致命信息走226 * 无推送权限 > 鉴权失败 > 未知。stderr 一并参与匹配——git 的致命信息走
@@ -224,13 +228,13 @@ export type CannbayErrorCode = 'no-credentials' | 'auth-failed' | 'forbidden' |
224 * 托管平台错误码区分:CH.00905401 = 凭据无效/密码式认证被拒(auth-failed),228 * 托管平台错误码区分:CH.00905401 = 凭据无效/密码式认证被拒(auth-failed),
225 * CH.00905403 = 凭据有效但对仓无写权限(forbidden)——真机实测文本两者229 * CH.00905403 = 凭据有效但对仓无写权限(forbidden)——真机实测文本两者
226 * 都含 "Authentication failed"/"Access denied" 字样,必须按码精确分诊。 */230 * 都含 "Authentication failed"/"Access denied" 字样,必须按码精确分诊。 */
227-export function classifyCannbayError(err: unknown): CannbayErrorCode {231+export function classifyLakeError(err: unknown): LakeErrorCode {
228 if (err instanceof MissingPushCredentialsError) return 'no-credentials';232 if (err instanceof MissingPushCredentialsError) return 'no-credentials';
229 const msg = err instanceof Error233 const msg = err instanceof Error
230 ? `${err.message}\n${String((err as { stderr?: Buffer }).stderr ?? '')}`234 ? `${err.message}\n${String((err as { stderr?: Buffer }).stderr ?? '')}`
231 : String(err);235 : String(err);
232 if (/quota|exceeds?\s+(the\s+)?(maximum|limit|size)|batch response:.*413|\b413\b/i.test(msg)) return 'quota';236 if (/quota|exceeds?\s+(the\s+)?(maximum|limit|size)|batch response:.*413|\b413\b/i.test(msg)) return 'quota';
233- if (/not allowed to push|forbidden|CH\.0090540[2-9]|\b403\b/i.test(msg)) return 'forbidden';237+ if (new RegExp(`not allowed to push|forbidden|${PLATFORM_FORBIDDEN_CODES.source}|\\b403\\b`, 'i').test(msg)) return 'forbidden';
234- if (/authentication failed|access denied|could not read username|terminal prompts disabled|invalid (username|token|password)|CH\.00905401|\b401\b/i.test(msg)) return 'auth-failed';238+ if (new RegExp(`authentication failed|access denied|could not read username|terminal prompts disabled|invalid (username|token|password)|${PLATFORM_AUTH_FAILED_CODES.source}|\\b401\\b`, 'i').test(msg)) return 'auth-failed';
235 return 'unknown';239 return 'unknown';
236}240}
Rpackages/insight/src/lib/cannbay2/export.ts→packages/insight/src/lib/lake/export.ts+13-11
@@ -7,12 +7,13 @@
7// See LICENSE in the root of the software repository for the full text of the License.7// See LICENSE in the root of the software repository for the full text of the License.
8 8 
9// opencode 来源会话没有捕获 jsonl,上传前由 insight DB 导出 claude-jsonl。9// opencode 来源会话没有捕获 jsonl,上传前由 insight DB 导出 claude-jsonl。
10-// 输出 = cannbay2 仓库布局(sessions/<sid>/<sid>.jsonl + sessions/<sid>/subagents/),10+// 输出 = lake 仓库布局(sessions/<sid>/<sid>.jsonl + sessions/<sid>/subagents/),
11// 重新导入经 claude-jsonl adapter 走标准管线。保真边界:turns/toolCalls/token11// 重新导入经 claude-jsonl adapter 走标准管线。保真边界:turns/toolCalls/token
12// 级数据齐全;system prompt / Full Context 不含(opencode 原生 db 没有)。12// 级数据齐全;system prompt / Full Context 不含(opencode 原生 db 没有)。
13import crypto from 'node:crypto';13import crypto from 'node:crypto';
14import fs from 'node:fs';14import fs from 'node:fs';
15import path from 'node:path';15import path from 'node:path';
16+import { envelopeFields } from '../shared/envelope';
16 17 
17interface ExportTurn {18interface ExportTurn {
18 id: string;19 id: string;
@@ -66,9 +67,10 @@ type MinimalPrisma = {
66 interactionBridge: { findMany(args: { where: { sessionId: string } }): Promise<ExportBridge[]> };67 interactionBridge: { findMany(args: { where: { sessionId: string } }): Promise<ExportBridge[]> };
67};68};
68 69 
69-// x_cannbay 命名空间(docs/cannbay-schema-spec.md):声明式权威源,双轨期70+// 信封构造(键名与双写经 shared/envelope 单一真身——ENVELOPE_KEY 常量族),
70-// 与 legacy 顶层字段(source/duration_ms/stopReason)并存,读方 x_cannbay 优先。71+// 双轨期与 legacy 顶层字段(source/duration_ms/stopReason)并存,读方按
71-export function xCannbay(schema: string, data: Record<string, unknown>) {72+// readEnvelope 双名优先级解析。
73+export function xLake(schema: string, data: Record<string, unknown>) {
72 return { schema, version: 1, data };74 return { schema, version: 1, data };
73}75}
74 76 
@@ -125,7 +127,7 @@ function turnGroupToLines(turns: ExportTurn[], toolCallsByTurn: Map<string, Expo
125 source: 'insight-export',127 source: 'insight-export',
126 duration_ms: t.latencyMs || undefined,128 duration_ms: t.latencyMs || undefined,
127 stopReason: t.finishReason ?? undefined,129 stopReason: t.finishReason ?? undefined,
128- x_cannbay: xCannbay('cc-db-turn', {130+ ...envelopeFields(xLake('cc-db-turn', {
129 latencyMs: t.latencyMs,131 latencyMs: t.latencyMs,
130 ttftMs: t.ttftMs ?? undefined,132 ttftMs: t.ttftMs ?? undefined,
131 stopReason: t.finishReason ?? undefined,133 stopReason: t.finishReason ?? undefined,
@@ -143,7 +145,7 @@ function turnGroupToLines(turns: ExportTurn[], toolCallsByTurn: Map<string, Expo
143 startedAt: tc.startedAt ? tc.startedAt.toISOString() : undefined,145 startedAt: tc.startedAt ? tc.startedAt.toISOString() : undefined,
144 })),146 })),
145 turnKind: (t.role as string) === 'system' ? 'system' : 'normal',147 turnKind: (t.role as string) === 'system' ? 'system' : 'normal',
146- }),148+ })),
147 }));149 }));
148 if (tcs.length > 0) {150 if (tcs.length > 0) {
149 lines.push(JSON.stringify({151 lines.push(JSON.stringify({
@@ -179,7 +181,7 @@ function turnGroupToLines(turns: ExportTurn[], toolCallsByTurn: Map<string, Expo
179 return lines;181 return lines;
180}182}
181 183 
182-// 导出为 cannbay2 仓库布局文件,返回写出的相对路径(相对 outDir)。184+// 导出为 lake 仓库布局文件,返回写出的相对路径(相对 outDir)。
183// framework:同 sid 双行(方案一)消歧——不带时裸 taskId findFirst 在双行下185// framework:同 sid 双行(方案一)消歧——不带时裸 taskId findFirst 在双行下
184// 取行不确定,可能把他行的轮次导出上传到错误记录类的目录(错误数据出机器)186// 取行不确定,可能把他行的轮次导出上传到错误记录类的目录(错误数据出机器)
185export async function exportSessionToClaudeJsonl(187export async function exportSessionToClaudeJsonl(
@@ -222,12 +224,12 @@ export async function exportSessionToClaudeJsonl(
222 fs.writeFileSync(224 fs.writeFileSync(
223 path.join(sessionDir, `${sid}.meta.json`),225 path.join(sessionDir, `${sid}.meta.json`),
224 JSON.stringify({226 JSON.stringify({
225- x_cannbay: xCannbay('cc-session-meta', {227+ ...envelopeFields(xLake('cc-session-meta', {
226 producer: 'insight-export',228 producer: 'insight-export',
227 framework: session.framework,229 framework: session.framework,
228 ccVersion: session.version ?? undefined,230 ccVersion: session.version ?? undefined,
229 sid,231 sid,
230- }),232+ })),
231 }, null, 2) + '\n',233 }, null, 2) + '\n',
232 );234 );
233 written.push(path.posix.join('sessions', sid, `${sid}.meta.json`));235 written.push(path.posix.join('sessions', sid, `${sid}.meta.json`));
@@ -251,12 +253,12 @@ export async function exportSessionToClaudeJsonl(
251 toolUseId: bridge?.dispatchToolCallId ?? null,253 toolUseId: bridge?.dispatchToolCallId ?? null,
252 name: bridge?.subagentName ?? null,254 name: bridge?.subagentName ?? null,
253 agentType: bridge?.subagentType ?? null,255 agentType: bridge?.subagentType ?? null,
254- x_cannbay: xCannbay('cc-subagent-meta', {256+ ...envelopeFields(xLake('cc-subagent-meta', {
255 toolUseId: bridge?.dispatchToolCallId ?? null,257 toolUseId: bridge?.dispatchToolCallId ?? null,
256 name: bridge?.subagentName ?? null,258 name: bridge?.subagentName ?? null,
257 agentType: bridge?.subagentType ?? null,259 agentType: bridge?.subagentType ?? null,
258 subagentSessionId: subId,260 subagentSessionId: subId,
259- }),261+ })),
260 };262 };
261 const metaFile = path.join(sessionDir, 'subagents', `${subId}.meta.json`);263 const metaFile = path.join(sessionDir, 'subagents', `${subId}.meta.json`);
262 fs.writeFileSync(metaFile, JSON.stringify(meta, null, 2) + '\n');264 fs.writeFileSync(metaFile, JSON.stringify(meta, null, 2) + '\n');
Rpackages/insight/src/lib/cannbay2/governance.ts→packages/insight/src/lib/lake/governance.ts+2-2文件内容审核中,请稍后刷新重试
Rpackages/insight/src/lib/cannbay2/index.ts→packages/insight/src/lib/lake/index.ts+37-38
@@ -6,31 +6,32 @@
6// INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.6// INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
7// See LICENSE in the root of the software repository for the full text of the License.7// See LICENSE in the root of the software repository for the full text of the License.
8 8 
9-// cannbay2 编排层(route 唯一入口):list / import / upload。9+// lake 编排层(route 唯一入口):list / import / upload。
10// 上传数据流:源(proxy 捕获 / native claude jsonl 原文件 / opencode DB 导出)10// 上传数据流:源(proxy 捕获 / native claude jsonl 原文件 / opencode DB 导出)
11// → staging → 治理(清洗 + 复检熔断,governance.ts)→ mirror.uploadFolder。11// → staging → 治理(清洗 + 复检熔断,governance.ts)→ mirror.uploadFolder。
12import fs from 'node:fs';12import fs from 'node:fs';
13import path from 'node:path';13import path from 'node:path';
14import os from 'node:os';14import os from 'node:os';
15-import { listSessions, listOverview, materializeSession, uploadFolder, listParadigmDirs, type Cannbay2Remote, type Cannbay2SessionEntry, type Cannbay2Overview, type UploadResult, type RecordingClass } from './mirror';15+import { listSessions, listOverview, materializeSession, uploadFolder, listParadigmDirs, type LakeRemote, type LakeSessionEntry, type LakeOverview, type UploadResult, type RecordingClass } from './mirror';
16import { readCcSessionMeta } from '../ingest/proxy-classify';16import { readCcSessionMeta } from '../ingest/proxy-classify';
17import { isProxyVersion } from '../shared/session-format';17import { isProxyVersion } from '../shared/session-format';
18import { computeSessionActiveSplit } from '../active-time';18import { computeSessionActiveSplit } from '../active-time';
19-import { defaultCannbay2Remote } from './remote';19+import { defaultLakeRemote } from './remote';
20import { requirePushCredentials } from './auth';20import { requirePushCredentials } from './auth';
21import { governText } from './governance';21import { governText } from './governance';
22-import { exportSessionToClaudeJsonl, xCannbay } from './export';22+import { exportSessionToClaudeJsonl, xLake } from './export';
23+import { readEnvelope, envelopeFields } from '../shared/envelope';
23import { readCaptureText, ensure7z, compressCaptureFile, decompressToPlain } from '@/lib/compression';24import { readCaptureText, ensure7z, compressCaptureFile, decompressToPlain } from '@/lib/compression';
24import { importSession } from '@/lib/ingest/data-service';25import { importSession } from '@/lib/ingest/data-service';
25import type { PrismaClient } from '@prisma/client';26import type { PrismaClient } from '@prisma/client';
26 27 
27-export type { Cannbay2SessionEntry, Cannbay2Overview };28+export type { LakeSessionEntry, LakeOverview };
28 29 
29// 平台单文件上限兜底(按压缩后字节)。导出为可变引用(测试注入小阈值30// 平台单文件上限兜底(按压缩后字节)。导出为可变引用(测试注入小阈值
30-// 验证熔断路径);生产初值可经 env CANNBAY2_MAX_SESSION_MB 覆写(LFS 模式31+// 验证熔断路径);生产初值可经 env LAKE_MAX_SESSION_MB 覆写(LFS 模式
31// 下平台限制在 LFS 对象侧,git blob 的 100MiB 钳制不再适用)32// 下平台限制在 LFS 对象侧,git blob 的 100MiB 钳制不再适用)
32export const MAX_SESSION_BYTES = { value: (() => {33export const MAX_SESSION_BYTES = { value: (() => {
33- const v = Number(process.env.CANNBAY2_MAX_SESSION_MB);34+ const v = Number(process.env.LAKE_MAX_SESSION_MB);
34 return Number.isFinite(v) && v > 0 ? Math.floor(v * 1024 * 1024) : 100 * 1024 * 1024;35 return Number.isFinite(v) && v > 0 ? Math.floor(v * 1024 * 1024) : 100 * 1024 * 1024;
35})() };36})() };
36 37 
@@ -43,12 +44,12 @@ export class GovernanceError extends Error {
43 44 
44type PrismaLike = PrismaClient;45type PrismaLike = PrismaClient;
45 46 
46-export function listCannbay2Sessions(remote?: Cannbay2Remote): Cannbay2SessionEntry[] {47+export function listLakeSessions(remote?: LakeRemote): LakeSessionEntry[] {
47 return listSessions(remote);48 return listSessions(remote);
48}49}
49 50 
50/** 列表 + 顶层目录合并读取(单轮 mirror fetch 服务导入对话框的范式下拉+会话表)。 */51/** 列表 + 顶层目录合并读取(单轮 mirror fetch 服务导入对话框的范式下拉+会话表)。 */
51-export function listCannbay2Overview(remote?: Cannbay2Remote): Cannbay2Overview {52+export function listLakeOverview(remote?: LakeRemote): LakeOverview {
52 return listOverview(remote);53 return listOverview(remote);
53}54}
54 55 
@@ -65,15 +66,15 @@ async function readSessionType(prisma: PrismaLike, rowId: string): Promise<{ fra
65 }66 }
66}67}
67 68 
68-export async function importCannbay2Session(sid: string, prisma: PrismaLike, remote?: Cannbay2Remote, recording: RecordingClass = 'capture') {69+export async function importLakeSession(sid: string, prisma: PrismaLike, remote?: LakeRemote, recording: RecordingClass = 'capture') {
69 const mainJsonl = materializeSession(sid, remote, recording);70 const mainJsonl = materializeSession(sid, remote, recording);
70 const result = await importSession(mainJsonl, sid, prisma, mainJsonl, 'claude-jsonl');71 const result = await importSession(mainJsonl, sid, prisma, mainJsonl, 'claude-jsonl');
71- // x_cannbay 补列(docs/cannbay-schema-spec.md):核心管线无通道、只存在于72+ // x_cannbay 补列(docs/lake-schema-spec.md):核心管线无通道、只存在于
72 // 声明里的列(Turn.ttftMs/temperature/maxTokens/modelId/providerId、73 // 声明里的列(Turn.ttftMs/temperature/maxTokens/modelId/providerId、
73 // ToolCall.errorType/errorMessage/durationMs/startedAt、Session.framework/74 // ToolCall.errorType/errorMessage/durationMs/startedAt、Session.framework/
74- // version)在 cannbay2 编排层恢复 —— 核心红线不动。补列/回读都按75+ // version)在 lake 编排层恢复 —— 核心红线不动。补列/回读都按
75 // result.sessionId(行 id)定位,同 sid 双行不串行76 // result.sessionId(行 id)定位,同 sid 双行不串行
76- await patchXCannbayColumns(mainJsonl, result.sessionId, prisma).catch(() => { /* 补列失败不阻塞导入 */ });77+ await patchXLakeColumns(mainJsonl, result.sessionId, prisma).catch(() => { /* 补列失败不阻塞导入 */ });
77 return { ...result, ...(await readSessionType(prisma, result.sessionId)) };78 return { ...result, ...(await readSessionType(prisma, result.sessionId)) };
78}79}
79 80 
@@ -92,7 +93,7 @@ interface DbTurnPayload {
92// 单条 toolUseId 匹配不到 → 跳过该条。93// 单条 toolUseId 匹配不到 → 跳过该条。
93// sessionRowId:目标行 id(importSession 返回值)——同 sid 双行下裸94// sessionRowId:目标行 id(importSession 返回值)——同 sid 双行下裸
94// taskId findFirst 会补到另一行上。95// taskId findFirst 会补到另一行上。
95-async function patchXCannbayColumns(mainJsonl: string, sessionRowId: string, prisma: PrismaLike): Promise<void> {96+async function patchXLakeColumns(mainJsonl: string, sessionRowId: string, prisma: PrismaLike): Promise<void> {
96 // 物化 stem 按记录类带后缀(capture=<sid>,native=<sid>.native)——与97 // 物化 stem 按记录类带后缀(capture=<sid>,native=<sid>.native)——与
97 // materializeSession 的 subagents 物化目录对齐;先剥 .7z/.gz 再剥 .jsonl98 // materializeSession 的 subagents 物化目录对齐;先剥 .7z/.gz 再剥 .jsonl
98 // (basename 后缀逐字匹配,一次传 .jsonl.7z 剥不掉)99 // (basename 后缀逐字匹配,一次传 .jsonl.7z 剥不掉)
@@ -109,8 +110,9 @@ async function patchXCannbayColumns(mainJsonl: string, sessionRowId: string, pri
109 for (const ln of text.split('\n')) {110 for (const ln of text.split('\n')) {
110 if (!ln.trim()) continue;111 if (!ln.trim()) continue;
111 try {112 try {
112- const xb = (JSON.parse(ln) as { x_cannbay?: { schema?: string; data?: DbTurnPayload } }).x_cannbay;113+ const parsedLn = JSON.parse(ln);
113- if (xb?.schema === 'cc-db-turn' && xb.data) payloads.push(xb.data);114+ const xb = readEnvelope(parsedLn);
115+ if (xb?.schema === 'cc-db-turn' && xb.data) payloads.push(xb.data as DbTurnPayload);
114 } catch { /* 截断行跳过 */ }116 } catch { /* 截断行跳过 */ }
115 }117 }
116 if (payloads.length > 0) fileGroups.push({ subId, payloads });118 if (payloads.length > 0) fileGroups.push({ subId, payloads });
@@ -200,7 +202,7 @@ interface SessionRow {
200async function stageSessionFiles(session: SessionRow, prisma: PrismaLike): Promise<{ stagingDir: string; sid: string }> {202async function stageSessionFiles(session: SessionRow, prisma: PrismaLike): Promise<{ stagingDir: string; sid: string }> {
201 const sid = session.taskId;203 const sid = session.taskId;
202 // staging 必须在镜像缓存目录之外:首次 clone / 镜像自愈会 rmSync 整个缓存目录204 // staging 必须在镜像缓存目录之外:首次 clone / 镜像自愈会 rmSync 整个缓存目录
203- const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), `cannbay2-staging-${sid}-`));205+ const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), `lake-staging-${sid}-`));
204 const sessionDir = path.join(stagingDir, 'sessions', sid);206 const sessionDir = path.join(stagingDir, 'sessions', sid);
205 fs.rmSync(stagingDir, { recursive: true, force: true });207 fs.rmSync(stagingDir, { recursive: true, force: true });
206 fs.mkdirSync(sessionDir, { recursive: true });208 fs.mkdirSync(sessionDir, { recursive: true });
@@ -244,12 +246,12 @@ async function stageSessionFiles(session: SessionRow, prisma: PrismaLike): Promi
244 fs.writeFileSync(246 fs.writeFileSync(
245 path.join(sessionDir, `${sid}.meta.json`),247 path.join(sessionDir, `${sid}.meta.json`),
246 JSON.stringify({248 JSON.stringify({
247- x_cannbay: xCannbay('cc-session-meta', {249+ ...envelopeFields(xLake('cc-session-meta', {
248 producer: 'insight-upload',250 producer: 'insight-upload',
249 framework: session.framework,251 framework: session.framework,
250 ccVersion: session.version ?? undefined,252 ccVersion: session.version ?? undefined,
251 sid,253 sid,
252- }),254+ })),
253 }, null, 2) + '\n',255 }, null, 2) + '\n',
254 );256 );
255 }257 }
@@ -357,22 +359,17 @@ export function extractSubmitter(description: string | undefined): { submitter:
357 return { submitter, error: null };359 return { submitter, error: null };
358}360}
359 361 
360-// 范式目录网关(与提交人同级强制):多根远端(默认仓)必填 category 且必须362+// 范式目录网关(与提交人同级强制):显式给 category 时必须是数据仓现存
361-// 是数据仓现存顶层目录(动态白名单,防 typo 凭空造目录);单根远端(自定义363+// 顶层目录(动态白名单,防 typo 凭空造目录)。范式注册表 = 数据仓顶层
362-// 数据湖)不启用范式目录。范式注册表 = 数据仓顶层目录,insight 零范式知识。364+// 目录,insight 零范式知识(sessions/ 存量区特判已退役——普通目录同待遇)。
363-function validateCategory(remote: Cannbay2Remote, category?: string): string | undefined {365+function validateCategory(remote: LakeRemote, category?: string): string | undefined {
364 const value = category?.trim() || undefined;366 const value = category?.trim() || undefined;
365- if (remote.paradigmRoots) {367+ // category 缺省落上传缺省目录(rootDir;''=仓库根根级位)
366- if (!value) {368+ if (value) {
367- throw new Error('范式目录必填:请选择上传目标范式目录(数据仓顶层目录,新增范式请先在数据仓建目录)');
368- }
369 if (!/^[\w.-]+$/.test(value)) throw new Error(`Invalid paradigm dir: "${value.slice(0, 40)}"`);369 if (!/^[\w.-]+$/.test(value)) throw new Error(`Invalid paradigm dir: "${value.slice(0, 40)}"`);
370- if (value === remote.rootDir) throw new Error(`"${value}" 是存量会话区,不能作为范式目录`);
371 if (!listParadigmDirs(remote).includes(value)) {370 if (!listParadigmDirs(remote).includes(value)) {
372 throw new Error(`范式目录 "${value}" 在数据仓中不存在:请先在数据仓新建该顶层目录后重试`);371 throw new Error(`范式目录 "${value}" 在数据仓中不存在:请先在数据仓新建该顶层目录后重试`);
373 }372 }
374- } else if (value) {
375- throw new Error('自定义数据湖远端不支持范式目录上传(范式目录仅对默认仓启用)');
376 }373 }
377 return value;374 return value;
378}375}
@@ -380,7 +377,7 @@ function validateCategory(remote: Cannbay2Remote, category?: string): string | u
380// 上传时补丁 meta(cc-session-meta 可选字段,spec §4.4"加可选字段不升版本"):377// 上传时补丁 meta(cc-session-meta 可选字段,spec §4.4"加可选字段不升版本"):
381// ① opGen——算子生成结果标注(人工勾选),列表"算子生成"列消费;378// ① opGen——算子生成结果标注(人工勾选),列表"算子生成"列消费;
382// ② 会话指标快照 startTime/endTime/durationMs/activeMs/totalTokens + 主模型379// ② 会话指标快照 startTime/endTime/durationMs/activeMs/totalTokens + 主模型
383-// model——上传时刻从 DB 会话行取值,与导入后列表展示同源同值,cannbay380+// model——上传时刻从 DB 会话行取值,与导入后列表展示同源同值,lake
384// viewer 未导入行也能显示381// viewer 未导入行也能显示
385// 开始/耗时/Token(含算子生成耗时优化图),无需先导入 insight。耗时快照382// 开始/耗时/Token(含算子生成耗时优化图),无需先导入 insight。耗时快照
386// 优先 activeMs(活跃口径,与会话详情 Wall Clock 拆分同源),durationMs383// 优先 activeMs(活跃口径,与会话详情 Wall Clock 拆分同源),durationMs
@@ -391,7 +388,7 @@ async function annotateSessionMeta(stagingDir: string, sid: string, session: Ses
391 const metaPath = path.join(stagingDir, 'sessions', sid, `${sid}.meta.json`);388 const metaPath = path.join(stagingDir, 'sessions', sid, `${sid}.meta.json`);
392 try {389 try {
393 const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));390 const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
394- const xb = meta?.x_cannbay;391+ const xb = readEnvelope(meta);
395 if (xb?.schema !== 'cc-session-meta' || !xb.data || typeof xb.data !== 'object') return;392 if (xb?.schema !== 'cc-session-meta' || !xb.data || typeof xb.data !== 'object') return;
396 if (opGen === 'success' || opGen === 'failure') xb.data.opGen = opGen;393 if (opGen === 'success' || opGen === 'failure') xb.data.opGen = opGen;
397 const start = session.startTime instanceof Date ? session.startTime : null;394 const start = session.startTime instanceof Date ? session.startTime : null;
@@ -419,15 +416,17 @@ async function annotateSessionMeta(stagingDir: string, sid: string, session: Ses
419 } catch { /* turns 查询失败:跳过 activeMs,不阻断上传 */ }416 } catch { /* turns 查询失败:跳过 activeMs,不阻断上传 */ }
420 if (Number.isFinite(session.totalTokens)) xb.data.totalTokens = session.totalTokens;417 if (Number.isFinite(session.totalTokens)) xb.data.totalTokens = session.totalTokens;
421 if (typeof session.model === 'string' && session.model) xb.data.model = session.model;418 if (typeof session.model === 'string' && session.model) xb.data.model = session.model;
419+ // 双名对称写回(setOpGen 同款):此前仅胜出键被更新,另一键留旧值
420+ Object.assign(meta, envelopeFields(xb));
422 fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');421 fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
423 } catch { /* meta 缺失/畸形:跳过标注,不阻断上传 */ }422 } catch { /* meta 缺失/畸形:跳过标注,不阻断上传 */ }
424}423}
425 424 
426-export async function uploadCannbay2Session(425+export async function uploadLakeSession(
427 prisma: PrismaLike,426 prisma: PrismaLike,
428 taskId: string,427 taskId: string,
429 description?: string,428 description?: string,
430- remote?: Cannbay2Remote,429+ remote?: LakeRemote,
431 category?: string,430 category?: string,
432 baseline?: boolean,431 baseline?: boolean,
433 opGen?: 'success' | 'failure',432 opGen?: 'success' | 'failure',
@@ -439,7 +438,7 @@ export async function uploadCannbay2Session(
439 if (!/^[\w.-]+$/.test(taskId)) throw new Error(`Invalid session id: "${taskId}"`);438 if (!/^[\w.-]+$/.test(taskId)) throw new Error(`Invalid session id: "${taskId}"`);
440 const { error: submitterError } = extractSubmitter(description);439 const { error: submitterError } = extractSubmitter(description);
441 if (submitterError) throw new Error(submitterError);440 if (submitterError) throw new Error(submitterError);
442- const rem = remote ?? defaultCannbay2Remote();441+ const rem = remote ?? defaultLakeRemote();
443 // https 远端推送凭据前置检查(本地 bare 测试远端不受影响):无凭据直接442 // https 远端推送凭据前置检查(本地 bare 测试远端不受影响):无凭据直接
444 // fail-fast 给配置指引——放行到 git push 只会在 401/终端提示上挂死,且443 // fail-fast 给配置指引——放行到 git push 只会在 401/终端提示上挂死,且
445 // staging/治理全成白工444 // staging/治理全成白工
@@ -447,8 +446,8 @@ export async function uploadCannbay2Session(
447 // baseline(范式基准会话):仅默认仓且必须选中范式;位置即身份 —— 勾选即446 // baseline(范式基准会话):仅默认仓且必须选中范式;位置即身份 —— 勾选即
448 // 换(新会话进 <paradigm>/baseline/,原 baseline 会话同 commit 移回范式447 // 换(新会话进 <paradigm>/baseline/,原 baseline 会话同 commit 移回范式
449 // 普通目录),无确认标记,远端现状即唯一事实源448 // 普通目录),无确认标记,远端现状即唯一事实源
450- if (baseline && (!rem.paradigmRoots || !category?.trim())) {449+ if (baseline && !category?.trim()) {
451- throw new Error('baseline 上传仅在默认仓且选中范式目录时可用');450+ throw new Error('baseline 上传必须选中范式目录');
452 }451 }
453 const categoryValue = validateCategory(rem, category);452 const categoryValue = validateCategory(rem, category);
454 const session = await prisma.session.findFirst({453 const session = await prisma.session.findFirst({
@@ -456,7 +455,7 @@ export async function uploadCannbay2Session(
456 }) as SessionRow | null;455 }) as SessionRow | null;
457 if (!session) throw new Error(`Session not found: "${taskId}"`);456 if (!session) throw new Error(`Session not found: "${taskId}"`);
458 457 
459- // 记录类(同 sid 双记录·cannbay 侧):wire 捕获行(version 带 -proxy 或458+ // 记录类(同 sid 双记录·lake 侧):wire 捕获行(version 带 -proxy 或
460 // framework 带 -proxy 后缀)落裸 <sid>/(cpx 直传/存量数据零迁移);459 // framework 带 -proxy 后缀)落裸 <sid>/(cpx 直传/存量数据零迁移);
461 // 其余(claude/opencode 原生行、insight 导出行)一律落 <sid>.native/——460 // 其余(claude/opencode 原生行、insight 导出行)一律落 <sid>.native/——
462 // 同 sid 的捕获与原生两份记录并存,互不覆盖461 // 同 sid 的捕获与原生两份记录并存,互不覆盖
Rpackages/insight/src/lib/cannbay2/lfs-setup.ts→packages/insight/src/lib/lake/lfs-setup.ts+2-2文件内容审核中,请稍后刷新重试
Rpackages/insight/src/lib/cannbay2/mirror.ts→packages/insight/src/lib/lake/mirror.ts+634-117
Rpackages/insight/src/app/cannbay_viewer/_lib/lake/notes.ts→packages/insight/src/lib/lake/notes.ts+28-24
@@ -11,7 +11,7 @@
11// {"ts","type":"note","text"}11// {"ts","type":"note","text"}
12// {"ts","type":"scorecard","totalScore","dimensions":[{dimensionId,name,score}],"computedAt"}12// {"ts","type":"scorecard","totalScore","dimensions":[{dimensionId,name,score}],"computedAt"}
13// 记录类型可扩展(后续评价维度/对比结论等直接加 type)——解析端未知 type 跳过,13// 记录类型可扩展(后续评价维度/对比结论等直接加 type)——解析端未知 type 跳过,
14-// 旧版 viewer 不炸(与 cannbay-schema-spec 声明式分发同思路)。.notes 前缀与14+// 旧版 viewer 不炸(与 lake-schema-spec 声明式分发同思路)。.notes 前缀与
15// 捕获文件 <sid>.jsonl[.7z|.gz] 命名空间区分。读 = git show 按需拉单个 blob15// 捕获文件 <sid>.jsonl[.7z|.gz] 命名空间区分。读 = git show 按需拉单个 blob
16// (partial clone);追加 = temp index plumbing(read-tree → hash-object →16// (partial clone);追加 = temp index plumbing(read-tree → hash-object →
17// update-index → write-tree → commit-tree → push)全程不 checkout——窄检出会把17// update-index → write-tree → commit-tree → push)全程不 checkout——窄检出会把
@@ -21,10 +21,12 @@ import fs from 'node:fs';
21import os from 'node:os';21import os from 'node:os';
22import path from 'node:path';22import path from 'node:path';
23import { spawn } from 'node:child_process';23import { spawn } from 'node:child_process';
24-import { ensureMirrorAsync, markLakePhase, resolveLakeRemote, revParseMain, runGit, runGitAsync, sessionFolderName } from './mirror';24+import { ensureMirrorAsync, markLakePhase, revParseMain, runGit, runGitAsync, sessionFolderOf } from './mirror';
25-import { redactLakeSecrets } from './remote';25+import { defaultLakeRemote } from './remote';
26+import { redactRemoteSecrets } from './remote';
26import type { LakeRemote } from './remote';27import type { LakeRemote } from './remote';
27-import { requirePushCredentials } from '@/lib/cannbay2/auth';28+import { requirePushCredentials } from '@/lib/lake/auth';
29+import { readEnvelope, envelopeFields } from '@/lib/shared/envelope';
28 30 
29export interface NoteEntry { ts: string; type: 'note'; text: string }31export interface NoteEntry { ts: string; type: 'note'; text: string }
30 32 
@@ -54,7 +56,7 @@ function validateSegment(kind: string, v: string): string {
54}56}
55 57 
56function sessionDir(s: SessionLoc): string {58function sessionDir(s: SessionLoc): string {
57- const folder = sessionFolderName(validateSegment('会话 id', s.sid), s.recording ?? 'capture');59+ const folder = sessionFolderOf(validateSegment('会话 id', s.sid), s.recording ?? 'capture');
58 return `${validateSegment('范式目录', s.paradigm)}/${s.isBaseline ? 'baseline/' : ''}${folder}`;60 return `${validateSegment('范式目录', s.paradigm)}/${s.isBaseline ? 'baseline/' : ''}${folder}`;
59}61}
60 62 
@@ -137,13 +139,13 @@ export function parseRecords(content: string | null): LakeRecord[] {
137 return out;139 return out;
138}140}
139 141 
140-export async function readRecords(s: SessionLoc, remote: LakeRemote = resolveLakeRemote()): Promise<LakeRecord[]> {142+export async function readRecords(s: SessionLoc, remote: LakeRemote = defaultLakeRemote()): Promise<LakeRecord[]> {
141 const dir = await ensureMirrorAsync(remote);143 const dir = await ensureMirrorAsync(remote);
142 return parseRecords(await showFile(dir, `origin/${remote.branch}:${notesPath(s)}`));144 return parseRecords(await showFile(dir, `origin/${remote.branch}:${notesPath(s)}`));
143}145}
144 146 
145// 提交信息字段以「下一标签或行尾」为界(格式:提交人: X 内容描述: Y 日志路径: Z 备注: W 框架/时间…)。147// 提交信息字段以「下一标签或行尾」为界(格式:提交人: X 内容描述: Y 日志路径: Z 备注: W 框架/时间…)。
146-// 冒号认半角/全角两种形态([::])——上传端校验与 cannbay2 的 parseCommitSubject148+// 冒号认半角/全角两种形态([::])——上传端校验与 lake 的 parseCommitSubject
147// 同口径,只认半角会把全角冒号上传的行解析成 null(描述/作者列显示 —)149// 同口径,只认半角会把全角冒号上传的行解析成 null(描述/作者列显示 —)
148const SUBMIT_LABELS = ['提交人', '内容描述', '日志路径', '备注', '框架', '时间'];150const SUBMIT_LABELS = ['提交人', '内容描述', '日志路径', '备注', '框架', '时间'];
149 151 
@@ -158,7 +160,7 @@ export function parseSubmitInfo(msg: string | null): SessionSubmitInfo {
158 return { author: pick('提交人'), description: pick('内容描述'), remark: pick('备注') };160 return { author: pick('提交人'), description: pick('内容描述'), remark: pick('备注') };
159}161}
160 162 
161-export async function readSubmitInfo(s: SessionLoc, remote: LakeRemote = resolveLakeRemote()): Promise<SessionSubmitInfo> {163+export async function readSubmitInfo(s: SessionLoc, remote: LakeRemote = defaultLakeRemote()): Promise<SessionSubmitInfo> {
162 const dir = await ensureMirrorAsync(remote);164 const dir = await ensureMirrorAsync(remote);
163 // log 新→旧持续覆写 → 留存最老一条非 viewer 提交 = 当前位置的首传(与165 // log 新→旧持续覆写 → 留存最老一条非 viewer 提交 = 当前位置的首传(与
164 // readLakeSessions 归属同口径):普通「提交人: …」格式的维护提交(meta166 // readLakeSessions 归属同口径):普通「提交人: …」格式的维护提交(meta
@@ -178,7 +180,7 @@ export async function readSubmitInfo(s: SessionLoc, remote: LakeRemote = resolve
178let writeQueue: Promise<unknown> = Promise.resolve();180let writeQueue: Promise<unknown> = Promise.resolve();
179 181 
180/**182/**
181- * 401 挑战-应答式 push(与 cannbay2 pushMirror 同方案):pushUrl 一律无凭据,183+ * 401 挑战-应答式 push(与 lake pushMirror 同方案):pushUrl 一律无凭据,
182 * 凭据经一次性 credential helper 供给——托管平台网关已拒"请求直带密码"的内嵌184 * 凭据经一次性 credential helper 供给——托管平台网关已拒"请求直带密码"的内嵌
183 * URL(proactive Basic 直接拒;同凭据经 401 挑战后重试可通过,2026-09-14185 * URL(proactive Basic 直接拒;同凭据经 401 挑战后重试可通过,2026-09-14
184 * 实测)。helper 脚本只落 0700 临时目录、用后即删,凭据不进命令行(失败消息186 * 实测)。helper 脚本只落 0700 临时目录、用后即删,凭据不进命令行(失败消息
@@ -186,7 +188,7 @@ let writeQueue: Promise<unknown> = Promise.resolve();
186 * 凭据确定性生效。pushAuth=null(本地 bare / 匿名推)直推。188 * 凭据确定性生效。pushAuth=null(本地 bare / 匿名推)直推。
187 */189 */
188async function pushLakeAsync(dir: string, remote: LakeRemote, commit: string): Promise<number> {190async function pushLakeAsync(dir: string, remote: LakeRemote, commit: string): Promise<number> {
189- // https 远端推送凭据前置检查:无凭据直接给出配置入口(与 cannbay2/cpx 同191+ // https 远端推送凭据前置检查:无凭据直接给出配置入口(与 lake/cpx 同
190 // 款 fail-fast——匿名 https 推送只会在 401/终端提示上挂死)192 // 款 fail-fast——匿名 https 推送只会在 401/终端提示上挂死)
191 requirePushCredentials(remote.pushUrl, remote.pushAuth);193 requirePushCredentials(remote.pushUrl, remote.pushAuth);
192 const refspec = `${commit}:refs/heads/${remote.branch}`;194 const refspec = `${commit}:refs/heads/${remote.branch}`;
@@ -197,7 +199,7 @@ async function pushLakeAsync(dir: string, remote: LakeRemote, commit: string): P
197 if (/[\r\n]/.test(username) || /[\r\n]/.test(password)) {199 if (/[\r\n]/.test(username) || /[\r\n]/.test(password)) {
198 throw new Error('push 凭据含换行符(credential helper 协议非法)');200 throw new Error('push 凭据含换行符(credential helper 协议非法)');
199 }201 }
200- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cannbay-viewer-auth-'));202+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lake-viewer-auth-'));
201 try {203 try {
202 const helper = path.join(tmp, 'helper.sh');204 const helper = path.join(tmp, 'helper.sh');
203 fs.writeFileSync(205 fs.writeFileSync(
@@ -224,7 +226,7 @@ function validateNoteText(text: string): string {
224 return t;226 return t;
225}227}
226 228 
227-export async function appendNote(s: SessionLoc, text: string, remote: LakeRemote = resolveLakeRemote()): Promise<LakeRecord[]> {229+export async function appendNote(s: SessionLoc, text: string, remote: LakeRemote = defaultLakeRemote()): Promise<LakeRecord[]> {
228 const t = validateNoteText(text);230 const t = validateNoteText(text);
229 const content = await enqueueWrite(s, notesPath(s), remote, old => {231 const content = await enqueueWrite(s, notesPath(s), remote, old => {
230 const base = old ? (old.endsWith('\n') ? old : `${old}\n`) : '';232 const base = old ? (old.endsWith('\n') ? old : `${old}\n`) : '';
@@ -235,7 +237,7 @@ export async function appendNote(s: SessionLoc, text: string, remote: LakeRemote
235 237 
236/** 编辑既有 note 记录:按 (ts + 原文本) 定位行,替换文本、保留原 ts;不改写评分卡快照 */238/** 编辑既有 note 记录:按 (ts + 原文本) 定位行,替换文本、保留原 ts;不改写评分卡快照 */
237export async function editNote(239export async function editNote(
238- s: SessionLoc, match: { ts: string; text: string }, newText: string, remote: LakeRemote = resolveLakeRemote(),240+ s: SessionLoc, match: { ts: string; text: string }, newText: string, remote: LakeRemote = defaultLakeRemote(),
239): Promise<LakeRecord[]> {241): Promise<LakeRecord[]> {
240 const t = validateNoteText(newText);242 const t = validateNoteText(newText);
241 const content = await enqueueWrite(s, notesPath(s), remote, old => {243 const content = await enqueueWrite(s, notesPath(s), remote, old => {
@@ -256,7 +258,7 @@ export async function editNote(
256export async function appendScorecard(258export async function appendScorecard(
257 s: SessionLoc,259 s: SessionLoc,
258 card: { totalScore: number | null; dimensions: ScorecardEntry['dimensions']; computedAt: string },260 card: { totalScore: number | null; dimensions: ScorecardEntry['dimensions']; computedAt: string },
259- remote: LakeRemote = resolveLakeRemote(),261+ remote: LakeRemote = defaultLakeRemote(),
260): Promise<LakeRecord[]> {262): Promise<LakeRecord[]> {
261 if (card.dimensions.length === 0) throw new Error('评分卡无维度数据');263 if (card.dimensions.length === 0) throw new Error('评分卡无维度数据');
262 const content = await enqueueWrite(s, notesPath(s), remote, old => {264 const content = await enqueueWrite(s, notesPath(s), remote, old => {
@@ -283,7 +285,7 @@ function paradigmTargetPath(paradigm: string): string {
283 return `${p}/${p}.jsonl`;285 return `${p}/${p}.jsonl`;
284}286}
285 287 
286-export async function readParadigmTarget(paradigm: string, remote: LakeRemote = resolveLakeRemote()): Promise<ParadigmTarget | null> {288+export async function readParadigmTarget(paradigm: string, remote: LakeRemote = defaultLakeRemote()): Promise<ParadigmTarget | null> {
287 const dir = await ensureMirrorAsync(remote);289 const dir = await ensureMirrorAsync(remote);
288 const content = await showFile(dir, `origin/${remote.branch}:${paradigmTargetPath(paradigm)}`);290 const content = await showFile(dir, `origin/${remote.branch}:${paradigmTargetPath(paradigm)}`);
289 if (!content) return null;291 if (!content) return null;
@@ -305,7 +307,7 @@ export async function readParadigmTarget(paradigm: string, remote: LakeRemote =
305}307}
306 308 
307export async function setParadigmTarget(309export async function setParadigmTarget(
308- paradigm: string, baselineMs: number | null, expectedMs: number | null, remote: LakeRemote = resolveLakeRemote(),310+ paradigm: string, baselineMs: number | null, expectedMs: number | null, remote: LakeRemote = defaultLakeRemote(),
309): Promise<void> {311): Promise<void> {
310 for (const v of [baselineMs, expectedMs]) {312 for (const v of [baselineMs, expectedMs]) {
311 if (v != null && (!Number.isFinite(v) || v <= 0)) throw new Error('目标耗时必须为正数(毫秒)');313 if (v != null && (!Number.isFinite(v) || v <= 0)) throw new Error('目标耗时必须为正数(毫秒)');
@@ -317,16 +319,18 @@ export async function setParadigmTarget(
317 }, 'paradigm target');319 }, 'paradigm target');
318}320}
319 321 
320-/** 修正上传时标注的算子生成结果:改写 meta.json 的 x_cannbay.data.opGen(null=清除标注) */322+/** 修正上传时标注的算子生成结果:改写 meta.json 信封 data.opGen(null=清除标注) */
321-export async function setOpGen(s: SessionLoc, value: OpGenValue, remote: LakeRemote = resolveLakeRemote()): Promise<void> {323+export async function setOpGen(s: SessionLoc, value: OpGenValue, remote: LakeRemote = defaultLakeRemote()): Promise<void> {
322 await enqueueWrite(s, metaPath(s), remote, old => {324 await enqueueWrite(s, metaPath(s), remote, old => {
323 if (!old) return null;325 if (!old) return null;
324 try {326 try {
325- const meta = JSON.parse(old) as { x_cannbay?: { data?: Record<string, unknown> } };327+ const meta = JSON.parse(old);
326- const data = meta.x_cannbay?.data;328+ const srcEnv = readEnvelope(meta);
327- if (!data || typeof data !== 'object') return null;329+ const data = srcEnv?.data;
330+ if (!data) return null;
328 if (value == null) delete data.opGen;331 if (value == null) delete data.opGen;
329 else data.opGen = value;332 else data.opGen = value;
333+ if (srcEnv) Object.assign(meta, envelopeFields(srcEnv));
330 return `${JSON.stringify(meta, null, 2)}\n`;334 return `${JSON.stringify(meta, null, 2)}\n`;
331 } catch { return null; }335 } catch { return null; }
332 }, 'meta opgen');336 }, 'meta opgen');
@@ -355,7 +359,7 @@ async function writeLakeFileLocked(
355 const old = await showFile(dir, `origin/${remote.branch}:${relPath}`);359 const old = await showFile(dir, `origin/${remote.branch}:${relPath}`);
356 const content = mutate(old);360 const content = mutate(old);
357 if (content === null) throw new NotesConflictError('记录不存在或已被修改,请刷新后重试');361 if (content === null) throw new NotesConflictError('记录不存在或已被修改,请刷新后重试');
358- const idx = path.join(os.tmpdir(), `cannbay-lake-index-${process.pid}-${Date.now()}`);362+ const idx = path.join(os.tmpdir(), `lake-index-${process.pid}-${Date.now()}`);
359 try {363 try {
360 markLakePhase('fetch', `推送实验记录(${s.sid})…`);364 markLakePhase('fetch', `推送实验记录(${s.sid})…`);
361 const env = { ...process.env, GIT_INDEX_FILE: idx };365 const env = { ...process.env, GIT_INDEX_FILE: idx };
@@ -364,7 +368,7 @@ async function writeLakeFileLocked(
364 runGit(`git update-index --add --cacheinfo "100644,${blob},${relPath}"`, dir, 120_000, { env });368 runGit(`git update-index --add --cacheinfo "100644,${blob},${relPath}"`, dir, 120_000, { env });
365 const tree = runGit('git write-tree', dir, 120_000, { env }).toString('utf8').trim();369 const tree = runGit('git write-tree', dir, 120_000, { env }).toString('utf8').trim();
366 const commit = runGit(370 const commit = runGit(
367- `git -c user.name=cannbay-viewer -c user.email=viewer@localhost commit-tree "${tree}" -p "${parent}" -m "notes(viewer): ${s.sid} ${label}"`,371+ `git -c user.name=lake-viewer -c user.email=viewer@localhost commit-tree "${tree}" -p "${parent}" -m "notes(viewer): ${s.sid} ${label}"`,
368 dir, 120_000, { env },372 dir, 120_000, { env },
369 ).toString('utf8').trim();373 ).toString('utf8').trim();
370 const code = await pushLakeAsync(dir, remote, commit);374 const code = await pushLakeAsync(dir, remote, commit);
@@ -388,5 +392,5 @@ async function writeLakeFileLocked(
388 }392 }
389 markLakePhase('idle');393 markLakePhase('idle');
390 if (lastErr instanceof NotesConflictError) throw lastErr;394 if (lastErr instanceof NotesConflictError) throw lastErr;
391- throw lastErr instanceof Error ? new Error(redactLakeSecrets(lastErr.message, remote)) : new Error('推送失败');395+ throw lastErr instanceof Error ? new Error(redactRemoteSecrets(lastErr.message, remote, [])) : new Error('推送失败');
392}396}
@@ -0,0 +1,52 @@
1+// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd.
2+// This program is free software; you can redistribute it and/or modify it under the terms and conditions of
3+// CANN Open Software License Agreement Version 2.0 (the "License").
4+// Please refer to the License for details. You may not use this file except in compliance with the License.
5+// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,
6+// INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
7+// See LICENSE in the root of the software repository for the full text of the License.
8+ 
9+// 托管平台行为适配(P3 平台化收口):特定托管平台的差异行为唯一
10+// 居所——引擎其余部分零平台假设(GenericGitLake 语义)。三类适配:
11+// ① host 归一:ai.gitcode.com 不提供 git http 服务(302 首页),归一为
12+// gitcode.com——不改写则 clone 失败;共用镜像场景下还会因 origin 不匹
13+// 配被判"镜像不可用"触发破坏性重克隆
14+// ② 错误码分诊:CH.00905401 = 凭据无效/密码式认证被拒(auth-failed),
15+// CH.00905403 = 凭据有效但对仓无写权限(forbidden)——真机实测文本
16+// 两者都含 "Authentication failed"/"Access denied" 字样,必须按码精
17+// 确分诊(错误码是平台行为断言,保留真实值——改了分诊就坏)
18+// ③ 令牌属主反查:gitcode 可经 api/v5/user 反查令牌真实属主(端点实测
19+// 存在,401 = token 无效),用于测试连接时明示令牌属于谁
20+// 其他平台(GitHub/Gitee/GitLab/自建)无对应行为 = 走引擎通用语义,无需
21+// 适配文件;新平台差异在此追加。
22+ 
23+/** host 归一(URL 对象原地改写):仅 ai.gitcode.com → gitcode.com */
24+export function normalizeHost(u: URL): void {
25+ if (u.host === 'ai.gitcode.com') u.host = 'gitcode.com';
26+}
27+ 
28+/** 平台错误码注入错误消息分类(正则片段,消费方拼进分类正则):分诊表见文件头 ② */
29+export const PLATFORM_FORBIDDEN_CODES = /CH\.0090540[2-9]/;
30+export const PLATFORM_AUTH_FAILED_CODES = /CH\.00905401/;
31+ 
32+/** 令牌属主反查(仅 gitcode;其他平台返回 null = 不支持)。
33+ * 令牌一律走 Authorization header——URL query 参数会进 HTTP 访问日志/反代
34+ * 日志(凭据侧录反模式),v5 API 官方支持 Bearer 头(本仓工具链同款) */
35+export async function tokenOwner(pullUrl: string, secret: string): Promise<string | null> {
36+ if (!secret) return null;
37+ try {
38+ const u = new URL(pullUrl);
39+ if (u.host !== 'gitcode.com') return null;
40+ const res = await fetch('https://gitcode.com/api/v5/user', {
41+ headers: { Authorization: `Bearer ${secret}` },
42+ signal: AbortSignal.timeout(10_000),
43+ });
44+ if (!res.ok) return null;
45+ const j = await res.json() as Record<string, unknown>;
46+ if (typeof j.login === 'string' && j.login) return j.login;
47+ if (typeof j.username === 'string' && j.username) return j.username;
48+ return null;
49+ } catch {
50+ return null;
51+ }
52+}
Rpackages/insight/src/lib/cannbay2/remote.ts→packages/insight/src/lib/lake/remote.ts+92-109
Apackages/insight/src/lib/shared/envelope.ts+42-0文件内容审核中,请稍后刷新重试
Mpackages/insight/tests/db-race.ts+1-1文件内容审核中,请稍后刷新重试
Mpackages/insight/tests/e2e/e2e-import-observe.test.ts+2-2文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/e2e/cannbay2-baseline.test.ts→packages/insight/tests/e2e/lake-baseline.test.ts+30-33
Rpackages/insight/tests/e2e/cannbay2-dual-recording.test.ts→packages/insight/tests/e2e/lake-dual-recording.test.ts+11-11
Rpackages/insight/tests/e2e/cannbay2-extra.test.ts→packages/insight/tests/e2e/lake-extra.test.ts+40-40
Rpackages/insight/tests/e2e/cannbay2-lfs-gitattributes.test.ts→packages/insight/tests/e2e/lake-lfs-gitattributes.test.ts+3-3文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/e2e/cannbay2-lfs-immunity.test.ts→packages/insight/tests/e2e/lake-lfs-immunity.test.ts+13-13
Rpackages/insight/tests/e2e/cannbay2-lfs-setup.test.ts→packages/insight/tests/e2e/lake-lfs-setup.test.ts+2-2文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/e2e/cannbay2-lfs.test.ts→packages/insight/tests/e2e/lake-lfs.test.ts+7-8
Rpackages/insight/tests/e2e/cannbay2-mirror-init.test.ts→packages/insight/tests/e2e/lake-mirror-init.test.ts+22-19
Rpackages/insight/tests/e2e/cannbay2-opgen.test.ts→packages/insight/tests/e2e/lake-opgen.test.ts+18-18
Rpackages/insight/tests/e2e/cannbay2-paradigm.test.ts→packages/insight/tests/e2e/lake-paradigm.test.ts+98-51文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/e2e/cannbay2-prefetch.test.ts→packages/insight/tests/e2e/lake-prefetch.test.ts+12-12
Rpackages/insight/tests/e2e/cannbay2-remote-it.test.ts→packages/insight/tests/e2e/lake-remote-it.test.ts+56-46文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/e2e/cannbay2-remote.test.ts→packages/insight/tests/e2e/lake-remote.test.ts+174-170
Rpackages/insight/tests/e2e/cannbay-schema.test.ts→packages/insight/tests/e2e/lake-schema.test.ts+26-26文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/e2e/cannbay2.test.ts→packages/insight/tests/e2e/lake.test.ts+57-56文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/e2e/cannbay_viewer/notes-api.test.ts→packages/insight/tests/e2e/lake_viewer/notes-api.test.ts+30-30文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/e2e/cannbay_viewer/runs-api.test.ts→packages/insight/tests/e2e/lake_viewer/runs-api.test.ts+45-39文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/e2e/session-page-cannbay-upload.test.tsx→packages/insight/tests/e2e/session-page-lake-upload.test.tsx+10-5
Apackages/insight/tests/helpers/lake-env.ts+24-0文件内容审核中,请稍后刷新重试
Mpackages/insight/tests/ut/agent-md-session-recover.test.ts+1-1文件内容审核中,请稍后刷新重试
Mpackages/insight/tests/ut/audit-skillsift-api.test.ts+3-3文件内容审核中,请稍后刷新重试
Mpackages/insight/tests/ut/cli/unit/commands/auth.test.ts+8-6文件内容审核中,请稍后刷新重试
Mpackages/insight/tests/ut/cli/unit/commands/upload.test.ts+1-1文件内容审核中,请稍后刷新重试
Mpackages/insight/tests/ut/helpers/authed-git-http-server.mjs+1-1文件内容审核中,请稍后刷新重试
Mpackages/insight/tests/ut/import-history-dual-delete.test.tsx+5-5文件内容审核中,请稍后刷新重试
Mpackages/insight/tests/ut/import-proxy-native-coexist.test.ts+1-1文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/ut/cannbay-auth-it.test.ts→packages/insight/tests/ut/lake-auth-it.test.ts+25-22文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/ut/cannbay-credential-plan.test.ts→packages/insight/tests/ut/lake-credential-plan.test.ts+5-5文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/ut/cannbay2-lfs-resolution.test.ts→packages/insight/tests/ut/lake-lfs-resolution.test.ts+18-15文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/ut/cannbay2-lsremote-auth-it.test.ts→packages/insight/tests/ut/lake-lsremote-auth-it.test.ts+53-48文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/ut/cannbay2-test-account-it.test.ts→packages/insight/tests/ut/lake-test-account-it.test.ts+7-7
Rpackages/insight/tests/ut/cannbay-token-prompt.test.ts→packages/insight/tests/ut/lake-token-prompt.test.ts+13-15
Rpackages/insight/tests/ut/cannbay-zero-builtin.test.ts→packages/insight/tests/ut/lake-zero-builtin.test.ts+78-1
Rpackages/insight/tests/ut/cannbay_viewer/helpers/makeLake.ts→packages/insight/tests/ut/lake_viewer/helpers/makeLake.ts+0-0
Rpackages/insight/tests/ut/cannbay_viewer/insight-data-failure.test.ts→packages/insight/tests/ut/lake_viewer/insight-data-failure.test.ts+2-2文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/ut/cannbay_viewer/mirror.test.ts→packages/insight/tests/ut/lake_viewer/mirror.test.ts+36-32文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/ut/cannbay_viewer/runs.test.ts→packages/insight/tests/ut/lake_viewer/runs.test.ts+25-21文件内容审核中,请稍后刷新重试
Mpackages/insight/tests/ut/local-import-error-persist.test.tsx+22-9文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/ut/local-import-cannbay-dual.test.tsx→packages/insight/tests/ut/local-import-lake-dual.test.tsx+23-6文件内容审核中,请稍后刷新重试
Mpackages/insight/tests/ut/local-import-session-search.test.tsx+3-3文件内容审核中,请稍后刷新重试
Mpackages/insight/tests/ut/scoring-judge-log.test.ts+13-12文件内容审核中,请稍后刷新重试
Mpackages/insight/tests/ut/session-page-turn-accumulate.test.tsx+1-1文件内容审核中,请稍后刷新重试
Mpackages/insight/tests/ut/sevenz-import.test.ts+1-1文件内容审核中,请稍后刷新重试