已合并
[feat] 数据湖链路平台化:中性命名统一(env 前缀 breaking)+ 配置单源化 + 双栈单一引擎 + 信封双名 #49
duanbingzhe创建于 7 天前
[feat] 数据湖链路平台化:中性命名统一(env 前缀 breaking)+ 配置单源化 + 双栈单一引擎 + 信封双名 #49
已合并
共 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-22 | 10 | ## [v0.1.8] - 2026-09-22 |
| 6 | 11 | ||
| 7 | ### Added | 12 | ### Added |
| @@ -27,11 +27,11 @@ import path from 'node:path'; | |||
| 27 | import type { ProxyRecord, Protocol, AnthropicContentBlock, AnthropicUsage, OpenAIUsage } from './types'; | 27 | import type { ProxyRecord, Protocol, AnthropicContentBlock, AnthropicUsage, OpenAIUsage } from './types'; |
| 28 | import { sessionFilePath, sessionMetaPath, subagentFilePath, subagentMetaPath, appendClaudeLine, writeMeta, PROXY_DIR } from './writer'; | 28 | import { 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 it | 352 | // finish_reason → Turn.finishReason). `ttftMs` has no pipeline field, so it |
| 335 | // stays a pure extension field (declared in x_cannbay cc-wire-round; today | 353 | // 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. |
| 64 | const taskDispatch = new Map<string, { toolUseId: string | null; name: string | null; agentType: string | null }>(); | 64 | const 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 等文件级声明,紧邻主捕获文件。 |
| 43 | export function sessionMetaPath(sid: string): string { | 43 | export 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 only | 61 | - **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 Frontend | 66 | ### CLI Frontend |
| 67 | 67 | ||
| @@ -153,10 +153,10 @@ proxy(cpx 捕获)是 insight 的扩展来源,不是核心管线的一部 | |||
| 153 | 153 | ||
| 154 | ## Constraints & Gotchas | 154 | ## 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 only | 161 | - **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 components | 162 | - **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 | ### Fixed | 28 | ### 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 & Gotchas | 101 | ## 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 only | 109 | - **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 components | 110 | - **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 | end | 176 | end |
| 177 | 177 | ||
| @@ -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 | ||
| 9 | import { NextRequest, NextResponse } from 'next/server'; | 9 | import { 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'; |
| 15 | import { prisma } from '@/lib/db'; | 15 | import { prisma } from '@/lib/db'; |
| 16 | 16 | ||
| 17 | export const maxDuration = 300; | 17 | export const maxDuration = 300; |
| 18 | 18 | ||
| 19 | export async function POST(request: NextRequest) { | 19 | export 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 产出结构化 code | 26 | + // 错误出边界统一走这里:消息脱敏 + 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 捕获与 cannbay | 58 | + // ① 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'; | |||
| 10 | import { prisma } from '@/lib/db'; | 10 | import { prisma } from '@/lib/db'; |
| 11 | import { isClaudeFormatSession } from '@/lib/shared/session-format'; | 11 | import { isClaudeFormatSession } from '@/lib/shared/session-format'; |
| 12 | import { parseJsonlLines } from '@/lib/ingest/adapters/claude-jsonl'; | 12 | import { 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 capture | 15 | // Wire-rounds view: rebuild each WIRE REQUEST directly from the capture |
| 15 | // jsonl, independent of the DB/import pipeline. The file stores each message | 16 | // 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'; | |||
| 16 | import { | 16 | import { |
| 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 | ||
| 22 | export const maxDuration = 60; | 22 | export 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 of | 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"). | 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 | ||
| 11 | import { useEffect, useState } from 'react'; | 11 | import { useEffect, useState } from 'react'; |
| 12 | -import { formatDuration } from '@/app/cannbay_viewer/_lib/format'; | 12 | +import { formatDuration } from '@/app/lake_viewer/_lib/format'; |
| 13 | 13 | ||
| 14 | interface LakeStatus { | 14 | interface 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 | ||
| 11 | import { useEffect, useState } from 'react'; | 11 | import { 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'; |
| 13 | import type { RunItemView } from './RunsTable'; | 13 | import type { RunItemView } from './RunsTable'; |
| 14 | 14 | ||
| 15 | interface NoteEntry { ts: string; type: 'note'; text: string } | 15 | interface 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 | ||
| 11 | import { useMemo, useState } from 'react'; | 11 | import { 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'; |
| 13 | import { NotesDialog } from './NotesDialog'; | 13 | import { NotesDialog } from './NotesDialog'; |
| 14 | import { toast } from 'sonner'; | 14 | import { 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 聚合逻辑零改动。 |
| 12 | import { prisma } from '@/lib/db'; | 12 | import { prisma } from '@/lib/db'; |
| 13 | import { runScorecard, scorecardStore, type Scorecard } from '@/lib/scoring'; | 13 | import { runScorecard, scorecardStore, type Scorecard } from '@/lib/scoring'; |
| 14 | -import { importCannbay2Session } from '@/lib/cannbay2'; | 14 | +import { importLakeSession } from '@/lib/lake'; |
| 15 | import { computeSessionActiveSplit } from '@/lib/active-time'; | 15 | import { computeSessionActiveSplit } from '@/lib/active-time'; |
| 16 | import { isProxyVersion } from '@/lib/shared/session-format'; | 16 | import { 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>/ |
| 113 | export async function importSession(sid: string, recording: 'capture' | 'native' = 'capture'): Promise<{ ok: boolean; error?: string }> { | 113 | export 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)。 |
| 13 | import { getActiveDurationMs, getScorecard, listInsightSessions, mapBounded, type InsightScorecard, type InsightSession } from './insight-data'; | 13 | import { 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'; | ||
| 17 | import { normalizeScore } from './format'; | 18 | import { normalizeScore } from './format'; |
| 18 | import { agentFrameworkOf } from '@/lib/shared/session-format'; | 19 | import { agentFrameworkOf } from '@/lib/shared/session-format'; |
| 19 | 20 | ||
| @@ -110,15 +111,18 @@ interface LakeListing { | |||
| 110 | } | 111 | } |
| 111 | 112 | ||
| 112 | async function buildLakeListing(): Promise<LakeListing> { | 113 | async 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 | ||
| 9 | import { NextResponse } from 'next/server'; | 9 | import { NextResponse } from 'next/server'; |
| 10 | -import { lakeProgress } from '@/app/cannbay_viewer/_lib/lake/mirror'; | 10 | +import { lakeProgress } from '@/lib/lake/mirror'; |
| 11 | 11 | ||
| 12 | export async function GET() { | 12 | export 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 | ||
| 9 | import { NextResponse } from 'next/server'; | 9 | import { 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 | ||
| 12 | export async function GET(_req: Request, ctx: { params: Promise<{ name: string }> }) { | 12 | export 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 | ||
| 9 | import { NextResponse } from 'next/server'; | 9 | import { 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 | ||
| 13 | export async function GET() { | 13 | export 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 | ||
| 9 | import { NextResponse } from 'next/server'; | 9 | import { 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 | ||
| 9 | import { NextRequest, NextResponse } from 'next/server'; | 9 | import { 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 | ||
| 13 | function csv(p: string | null): string[] | undefined { | 13 | function csv(p: string | null): string[] | undefined { |
| 14 | if (!p) return undefined; | 14 | if (!p) return undefined; |
| @@ -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" | |||
| 24 | import { TurnTimeline } from "@/components/observe/TurnTimeline" | 24 | import { TurnTimeline } from "@/components/observe/TurnTimeline" |
| 25 | import { TurnDetail } from "@/components/observe/TurnDetail" | 25 | import { TurnDetail } from "@/components/observe/TurnDetail" |
| 26 | import { buildSubagentNumbers } from "@/lib/subagent-numbering" | 26 | import { buildSubagentNumbers } from "@/lib/subagent-numbering" |
| 27 | -import { UploadToCannbayDialog } from "@/components/UploadToCannbayDialog" | 27 | +import { UploadToLakeDialog } from "@/components/UploadToLakeDialog" |
| 28 | import { WireRounds } from "@/components/observe/WireRounds" | 28 | import { WireRounds } from "@/components/observe/WireRounds" |
| 29 | import { TurnContextPanel } from "@/components/observe/TurnContextPanel" | 29 | import { TurnContextPanel } from "@/components/observe/TurnContextPanel" |
| 30 | import { SkillDetail } from "@/components/observe/SkillDetail" | 30 | import { 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 | - <UploadToCannbayDialog | 1979 | + <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" | |||
| 17 | import { VERSION_DISPLAY } from "@/lib/version" | 17 | import { VERSION_DISPLAY } from "@/lib/version" |
| 18 | import { AIProviderConfigPanel } from "@/components/observe/AIProviderConfigPanel" | 18 | import { AIProviderConfigPanel } from "@/components/observe/AIProviderConfigPanel" |
| 19 | import { | 19 | import { |
| 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 | ||
| 30 | export default function SettingsPage() { | 27 | export 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) return | 94 | if (!hydrated) return |
| 79 | let active = true | 95 | 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) return | 99 | 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 | <Button | 444 | <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><sid>/</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> |
| @@ -19,8 +19,8 @@ import { spawnSync } from 'node:child_process'; | |||
| 19 | import { | 19 | import { |
| 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'; |
| 24 | import { formatSuccess, formatWarning } from '../utils/colors'; | 24 | import { formatSuccess, formatWarning } from '../utils/colors'; |
| 25 | 25 | ||
| 26 | export interface AuthDeps { | 26 | export 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 author | 57 | // 提交人必填:回车跳过(默认值为空)时重复提示 —— 空提交人的 commit author |
| 58 | -// 会顶替为机器身份 cannbot-insight(cannbay2/index.ts 网关同样强制拦截) | 58 | +// 会顶替为机器身份 cannbot-insight(lake/index.ts 网关同样强制拦截) |
| 59 | async function submitterPrompt(defaultVal: string): Promise<string> { | 59 | async 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 | ||
| 17 | import { useEffect, useState } from 'react' | 17 | import { useEffect, useState } from 'react' |
| 18 | -import { loadCannbayConfig } from '@/lib/cannbay-config' | 18 | + |
| 19 | import { Button } from '@/components/ui/button' | 19 | import { Button } from '@/components/ui/button' |
| 20 | import { Input } from '@/components/ui/input' | 20 | import { Input } from '@/components/ui/input' |
| 21 | import { | 21 | import { |
| @@ -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' |
| 37 | import { | 38 | import { |
| 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: boolean | 47 | open: boolean |
| 47 | onOpenChange: (open: boolean) => void | 48 | onOpenChange: (open: boolean) => void |
| 48 | /** 触发语境:no-credentials(未配置)/ auth-failed(令牌无效)/ forbidden(无写权限) */ | 49 | /** 触发语境:no-credentials(未配置)/ auth-failed(令牌无效)/ forbidden(无写权限) */ |
| 49 | - reason?: CannbayTokenReason | 50 | + reason?: LakeTokenReason |
| 50 | /** 保存成功回调(调用方可刷新自身凭据状态) */ | 51 | /** 保存成功回调(调用方可刷新自身凭据状态) */ |
| 51 | onSaved?: (info: { username: string; tokenMasked: string; verifiedAt?: string | null }) => void | 52 | onSaved?: (info: { username: string; tokenMasked: string; verifiedAt?: string | null }) => void |
| 52 | } | 53 | } |
| @@ -58,16 +59,21 @@ interface SavedReceipt { | |||
| 58 | reachable: boolean | 59 | 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) return | 95 | if (!open) return |
| 88 | let active = true | 96 | 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) return | 101 | 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) return | 137 | 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 | ||
| 10 | import { useState, useEffect, useCallback } from "react" | 10 | import { useState, useEffect, useCallback } from "react" |
| 11 | import { BRAND_NAME, BRAND_SLUG, BRAND_SOURCE_TYPE, BAY_NAME } from '@/lib/branding' | 11 | import { 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" |
| 14 | import { | 14 | import { |
| 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: string | 101 | sid: string |
| 102 | fileCount: number | 102 | fileCount: number |
| 103 | author: string | 103 | 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 | ||
| 133 | function formatSize(size: number): string { | 133 | function 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 fetch | 391 | // 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 | return | 434 | 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) return | 465 | + 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 = keys | 474 | 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 : null | 501 | 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 === key | 508 | 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>.native | 631 | // 记录类决定 <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` : sid | 633 | 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" && (allDone | 697 | {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 | <Button | 722 | <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 | <Input | 1028 | <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 = scoped | 1039 | 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 next | 1059 | 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 next | 1065 | 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 | Back | 1245 | 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 | Back | 1256 | 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 { | |||
| 24 | import { Button } from '@/components/ui/button'; | 24 | import { Button } from '@/components/ui/button'; |
| 25 | import { Badge } from '@/components/ui/badge'; | 25 | import { Badge } from '@/components/ui/badge'; |
| 26 | import { Checkbox } from '@/components/ui/checkbox'; | 26 | import { Checkbox } from '@/components/ui/checkbox'; |
| 27 | -import { UploadToCannbayDialog, type UploadCannbayTarget } from '@/components/UploadToCannbayDialog'; | 27 | +import { UploadToLakeDialog, type UploadLakeTarget } from '@/components/UploadToLakeDialog'; |
| 28 | import { Input } from '@/components/ui/input'; | 28 | import { Input } from '@/components/ui/input'; |
| 29 | import { | 29 | import { |
| 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 | - <UploadToCannbayDialog | 446 | + <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 | ||
| 16 | import { useEffect, useState } from 'react' | 16 | import { useEffect, useState } from 'react' |
| 17 | -import { loadCannbayConfig, cannbayRemoteInput, isDefaultCannbayRemote } from '@/lib/cannbay-config' | 17 | + |
| 18 | import { BAY_NAME } from '@/lib/branding' | 18 | import { BAY_NAME } from '@/lib/branding' |
| 19 | import { Button } from '@/components/ui/button' | 19 | import { Button } from '@/components/ui/button' |
| 20 | import { Checkbox } from '@/components/ui/checkbox' | 20 | import { 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' |
| 30 | import { | 30 | import { |
| 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: string | 39 | taskId: string |
| 40 | query?: string | null | 40 | query?: string | null |
| 41 | framework?: string | null | 41 | framework?: string | null |
| @@ -46,10 +46,10 @@ export interface UploadCannbayTarget { | |||
| 46 | user?: string | null | 46 | user?: string | null |
| 47 | } | 47 | } |
| 48 | 48 | ||
| 49 | -interface UploadToCannbayDialogProps { | 49 | +interface UploadToLakeDialogProps { |
| 50 | open: boolean | 50 | open: boolean |
| 51 | onOpenChange: (open: boolean) => void | 51 | onOpenChange: (open: boolean) => void |
| 52 | - target: UploadCannbayTarget | null | 52 | + target: UploadLakeTarget | null |
| 53 | onUploadStart?: () => void | 53 | onUploadStart?: () => void |
| 54 | onResult?: (result: { ok: boolean; message?: string }) => void | 54 | 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) return | 125 | + if (!open) return |
| 132 | let active = true | 126 | 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) return | 134 | 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) return | 151 | + if (!open) return |
| 155 | let active = true | 152 | 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) return | 156 | 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) return | 171 | 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 | } |
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | ||
| 9 | import fs from 'node:fs'; | 9 | import fs from 'node:fs'; |
| 10 | import { listSubagentSessions, parseJsonlLines } from '@/lib/ingest/adapters/claude-jsonl'; | 10 | import { 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 branding | 15 | // Data format identifier — always "cannbot-insight" regardless of branding |
| 16 | const BRAND_SOURCE_TYPE = "cannbot-insight" | 16 | const BRAND_SOURCE_TYPE = "cannbot-insight" |
| 17 | 17 | ||
| 18 | -// 数据之湖显示名:CANNBot 品牌用 "CANNbay",其他品牌用 "contextBay" | 18 | +// 数据湖显示名:CANNBot 品牌用 "CANNbay",其他品牌用 "contextBay" |
| 19 | const BAY_NAME = BRAND_SLUG === "cannbot-insight" ? "CANNbay" : "contextBay" | 19 | const BAY_NAME = BRAND_SLUG === "cannbot-insight" ? "CANNbay" : "contextBay" |
| 20 | 20 | ||
| 21 | -// viewer 页显示名(首页入口按钮 + viewer 标题):CANNBot 品牌用 "CANNBay Viewer", | 21 | +// viewer 页显示名(首页入口按钮 + viewer 标题):品牌配对(打包翻转规则见 pack-context) |
| 22 | -// 其他品牌用 "contextBay Viewer"(打包规则表配对翻转,哨兵测试锁零泄漏) | ||
| 23 | const BAY_VIEWER_NAME = BRAND_SLUG === "cannbot-insight" ? "CANNBay Viewer" : "contextBay Viewer" | 22 | const BAY_VIEWER_NAME = BRAND_SLUG === "cannbot-insight" ? "CANNBay Viewer" : "contextBay Viewer" |
| 24 | 23 | ||
| 25 | export { BRAND_NAME, BRAND_SLUG, BRAND_DESCRIPTION, BRAND_CONFIG_DIR_SUFFIX, BRAND_CLI_ALIAS, BRAND_SOURCE_TYPE, BAY_NAME, BAY_VIEWER_NAME } | 24 | export { 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'; | ||
| 24 | import { classifyProxyCapture } from '../proxy-classify'; | 25 | import { classifyProxyCapture } from '../proxy-classify'; |
| 25 | import { | 26 | import { |
| 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 v1 | 188 | // 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 @@ | |||
| 9 | import fs from 'node:fs'; | 9 | import fs from 'node:fs'; |
| 10 | import path from 'node:path'; | 10 | import path from 'node:path'; |
| 11 | import { readCaptureBuffer, readCaptureText as readArchivedText } from '@/lib/compression'; | 11 | import { readCaptureBuffer, readCaptureText as readArchivedText } from '@/lib/compression'; |
| 12 | +import { readEnvelope } from '@/lib/shared/envelope'; | ||
| 12 | import type { SessionListItem, RawInteraction, ToolCallInfo, TokenUsage } from '../../shared/types'; | 13 | import type { SessionListItem, RawInteraction, ToolCallInfo, TokenUsage } from '../../shared/types'; |
| 13 | 14 | ||
| 14 | // Shared scan helpers (claude-format concepts, also reused by the proxy | 15 | // 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 | ||
| 558 | function extractVersionFromLines(lines: ClaudeJsonlLine[]): string | null { | 558 | function 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 没有的 reasoningTokens | 751 | + // 声明式信封通道(优先):同一信息的权威源 + 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; |
| @@ -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 指纹兜底 |
| 23 | import fs from 'node:fs'; | 23 | import fs from 'node:fs'; |
| 24 | import { parseJsonlLines, extractSystemText, type ContentBlock } from './adapters/claude-jsonl'; | 24 | import { parseJsonlLines, extractSystemText, type ContentBlock } from './adapters/claude-jsonl'; |
| 25 | +import { readEnvelope } from '@/lib/shared/envelope'; | ||
| 25 | 26 | ||
| 26 | export type ProxyAgentFramework = 'opencode' | 'claude-code' | 'codex' | 'cannbot' | null; | 27 | export 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 上传直传快路径的信任凭证。 */ |
| 74 | export interface CcSessionMeta { | 75 | export 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 | } |
| @@ -8,17 +8,22 @@ | |||
| 8 | 8 | ||
| 9 | import { BRAND_SLUG } from "@/lib/branding" | 9 | import { 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?: string | 25 | address?: string |
| 21 | - authMode?: CannbayAuthMode | 26 | + authMode?: LakeAuthMode |
| 22 | account?: string | 27 | account?: string |
| 23 | password?: string | 28 | password?: string |
| 24 | token?: string | 29 | token?: string |
| @@ -28,9 +33,9 @@ export interface CannbayRemoteInput { | |||
| 28 | lfs?: boolean | 33 | lfs?: boolean |
| 29 | } | 34 | } |
| 30 | 35 | ||
| 31 | -export interface CannbayConfig extends CannbayRemoteInput { | 36 | +export interface LakeConfig extends LakeRemoteInput { |
| 32 | address: string | 37 | address: string |
| 33 | - authMode: CannbayAuthMode | 38 | + authMode: LakeAuthMode |
| 34 | account: string | 39 | account: string |
| 35 | password: string | 40 | password: string |
| 36 | token: string | 41 | token: string |
| @@ -38,16 +43,15 @@ export interface CannbayConfig extends CannbayRemoteInput { | |||
| 38 | directory: string | 43 | 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 null | 47 | 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_ADDRESS | 52 | + 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) : null | 54 | + 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 null | 69 | 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 null | 72 | if (!raw) return null |
| 69 | - return migrateCannbayConfig(JSON.parse(raw)) | 73 | + return migrateLakeConfig(JSON.parse(raw)) |
| 70 | } catch { | 74 | } catch { |
| 71 | return null | 75 | 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") return | 87 | 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 引导重配) |
| 106 | export type CredentialAction = "save-token" | "save-password" | "clear" | "keep" | 97 | export 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' |
| 18 | import { BAY_NAME } from '@/lib/branding' | 18 | import { 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 的远端条目(令牌只回脱敏尾段) */ |
| 23 | export interface StoredRemoteInfo { | 23 | export interface StoredRemoteInfo { |
| 24 | url: string | 24 | url: string |
| 25 | username: string | 25 | 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_ADDRESS | 32 | + // 默认仓退役:地址只从用户配置来(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 同语义,客户端轻量版)。 */ |
| 37 | function normalizeAuthKeyClient(url: string): string { | 38 | function 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 后缀双向互认——设置页手输地址与解析层规范地址可差一个后缀)。 */ |
| 51 | export function findStoredAuthEntry(remotes: StoredRemoteInfo[], address: string): StoredRemoteInfo | null { | 77 | export 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: '令牌无效或已过期', |
| @@ -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 credentials | 9 | // 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-chain | 11 | +// 旧名 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 | ||
| 25 | import fs from 'node:fs'; | 20 | import fs from 'node:fs'; |
| 21 | +import { PLATFORM_AUTH_FAILED_CODES, PLATFORM_FORBIDDEN_CODES } from './platform'; | ||
| 26 | import os from 'node:os'; | 22 | import os from 'node:os'; |
| 27 | import path from 'node:path'; | 23 | import 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 | +// (避免版本回滚丢凭据)。新文件存在即为权威(用户主动清空不复活旧凭据)。 | ||
| 48 | export function authFilePath(): string { | 46 | export 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-fast | 70 | +// 损坏/缺文件 → 空表(凭据未配置),绝不抛——解析链消费方按 null 走 fail-fast。 |
| 71 | +// 新名文件存在即权威;不存在则读旧名兜底(存量迁移,见 authFilePath 注释) | ||
| 66 | function readAuthFile(): AuthFileShape { | 72 | function 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 Error | 233 | 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 | } |
| @@ -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/token | 11 | // 重新导入经 claude-jsonl adapter 走标准管线。保真边界:turns/toolCalls/token |
| 12 | // 级数据齐全;system prompt / Full Context 不含(opencode 原生 db 没有)。 | 12 | // 级数据齐全;system prompt / Full Context 不含(opencode 原生 db 没有)。 |
| 13 | import crypto from 'node:crypto'; | 13 | import crypto from 'node:crypto'; |
| 14 | import fs from 'node:fs'; | 14 | import fs from 'node:fs'; |
| 15 | import path from 'node:path'; | 15 | import path from 'node:path'; |
| 16 | +import { envelopeFields } from '../shared/envelope'; | ||
| 16 | 17 | ||
| 17 | interface ExportTurn { | 18 | interface 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 | // 取行不确定,可能把他行的轮次导出上传到错误记录类的目录(错误数据出机器) |
| 185 | export async function exportSessionToClaudeJsonl( | 187 | export 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文件内容审核中,请稍后刷新重试
| @@ -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。 |
| 12 | import fs from 'node:fs'; | 12 | import fs from 'node:fs'; |
| 13 | import path from 'node:path'; | 13 | import path from 'node:path'; |
| 14 | import os from 'node:os'; | 14 | import 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'; |
| 16 | import { readCcSessionMeta } from '../ingest/proxy-classify'; | 16 | import { readCcSessionMeta } from '../ingest/proxy-classify'; |
| 17 | import { isProxyVersion } from '../shared/session-format'; | 17 | import { isProxyVersion } from '../shared/session-format'; |
| 18 | import { computeSessionActiveSplit } from '../active-time'; | 18 | import { computeSessionActiveSplit } from '../active-time'; |
| 19 | -import { defaultCannbay2Remote } from './remote'; | 19 | +import { defaultLakeRemote } from './remote'; |
| 20 | import { requirePushCredentials } from './auth'; | 20 | import { requirePushCredentials } from './auth'; |
| 21 | import { governText } from './governance'; | 21 | import { governText } from './governance'; |
| 22 | -import { exportSessionToClaudeJsonl, xCannbay } from './export'; | 22 | +import { exportSessionToClaudeJsonl, xLake } from './export'; |
| 23 | +import { readEnvelope, envelopeFields } from '../shared/envelope'; | ||
| 23 | import { readCaptureText, ensure7z, compressCaptureFile, decompressToPlain } from '@/lib/compression'; | 24 | import { readCaptureText, ensure7z, compressCaptureFile, decompressToPlain } from '@/lib/compression'; |
| 24 | import { importSession } from '@/lib/ingest/data-service'; | 25 | import { importSession } from '@/lib/ingest/data-service'; |
| 25 | import type { PrismaClient } from '@prisma/client'; | 26 | import 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 钳制不再适用) |
| 32 | export const MAX_SESSION_BYTES = { value: (() => { | 33 | export 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 | ||
| 44 | type PrismaLike = PrismaClient; | 45 | type 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 再剥 .jsonl | 98 | // 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 { | |||
| 200 | async function stageSessionFiles(session: SessionRow, prisma: PrismaLike): Promise<{ stagingDir: string; sid: string }> { | 202 | async 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 会话行取值,与导入后列表展示同源同值,cannbay | 380 | +// model——上传时刻从 DB 会话行取值,与导入后列表展示同源同值,lake |
| 384 | // viewer 未导入行也能显示 | 381 | // viewer 未导入行也能显示 |
| 385 | // 开始/耗时/Token(含算子生成耗时优化图),无需先导入 insight。耗时快照 | 382 | // 开始/耗时/Token(含算子生成耗时优化图),无需先导入 insight。耗时快照 |
| 386 | // 优先 activeMs(活跃口径,与会话详情 Wall Clock 拆分同源),durationMs | 383 | // 优先 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/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 按需拉单个 blob | 15 | // 捕获文件 <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'; | |||
| 21 | import os from 'node:os'; | 21 | import os from 'node:os'; |
| 22 | import path from 'node:path'; | 22 | import path from 'node:path'; |
| 23 | import { spawn } from 'node:child_process'; | 23 | import { 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'; | ||
| 26 | import type { LakeRemote } from './remote'; | 27 | import 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 | ||
| 29 | export interface NoteEntry { ts: string; type: 'note'; text: string } | 31 | export 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 | ||
| 56 | function sessionDir(s: SessionLoc): string { | 58 | function 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 的 parseCommitSubject | 148 | +// 冒号认半角/全角两种形态([::])——上传端校验与 lake 的 parseCommitSubject |
| 147 | // 同口径,只认半角会把全角冒号上传的行解析成 null(描述/作者列显示 —) | 149 | // 同口径,只认半角会把全角冒号上传的行解析成 null(描述/作者列显示 —) |
| 148 | const SUBMIT_LABELS = ['提交人', '内容描述', '日志路径', '备注', '框架', '时间']; | 150 | const 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 归属同口径):普通「提交人: …」格式的维护提交(meta | 166 | // readLakeSessions 归属同口径):普通「提交人: …」格式的维护提交(meta |
| @@ -178,7 +180,7 @@ export async function readSubmitInfo(s: SessionLoc, remote: LakeRemote = resolve | |||
| 178 | let writeQueue: Promise<unknown> = Promise.resolve(); | 180 | let 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-14 | 185 | * 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 | */ |
| 188 | async function pushLakeAsync(dir: string, remote: LakeRemote, commit: string): Promise<number> { | 190 | async 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;不改写评分卡快照 */ |
| 237 | export async function editNote( | 239 | export 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( | |||
| 256 | export async function appendScorecard( | 258 | export 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 | ||
| 307 | export async function setParadigmTarget( | 309 | export 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/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-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
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文件内容审核中,请稍后刷新重试
Rpackages/insight/tests/ut/local-import-cannbay-dual.test.tsx→packages/insight/tests/ut/local-import-lake-dual.test.tsx+23-6文件内容审核中,请稍后刷新重试