已合并
[feat] insight v0.2.29 / cpx v0.1.4: CANNBay LFS 上传模式 + context 双名发布;修复 push 鉴权与构建缓存误判 #14
duanbingzhe创建于 26 天前
[feat] insight v0.2.29 / cpx v0.1.4: CANNBay LFS 上传模式 + context 双名发布;修复 push 鉴权与构建缓存误判 #14
已合并
duanbingzhe创建于 26 天前
共 55 个文件变更+1951-159
@@ -33,6 +33,8 @@ tests/**/sandboxes/
33*.sqlite333*.sqlite3
34captures/34captures/
35*.tgz35*.tgz
36+.context-pack/
37+**/.context-pack/
36 38 
37# IDE 与系统文件39# IDE 与系统文件
38.idea/40.idea/
@@ -6,7 +6,7 @@
6 6 
7CANNBot-Sentry 是面向 LLM 编码 Agent(opencode / Claude Code)的**观测 · 审计 · 评测**哨站。Agent 在场上跑,Sentry 在场边:**记录现场 · 复盘事实 · 评鉴质量**。7CANNBot-Sentry 是面向 LLM 编码 Agent(opencode / Claude Code)的**观测 · 审计 · 评测**哨站。Agent 在场上跑,Sentry 在场边:**记录现场 · 复盘事实 · 评鉴质量**。
8 8 
9-> 仓库状态:新建仓库。正在将 [cannbot-skills](https://gitcode.com/cann/cannbot-skills)(`plugins-community/cannbot-insight`)的主体代码迁入 `packages/insight` 与 `packages/cpx`,详见[目录结构](#目录结构)。9+> 仓库状态:`packages/insight` 与 `packages/cpx` 已迁入落地(并已发布 npm);`packages/sift`(审计 / 评测)规划中、待迁入,详见[目录结构](#目录结构)。
10 10 
11## 核心能力11## 核心能力
12 12 
@@ -33,6 +33,9 @@ cd cannbot-sentry
33```bash33```bash
34npm install -g cannbot-insight # insight 观测平台(cannbot-insight / cbi)34npm install -g cannbot-insight # insight 观测平台(cannbot-insight / cbi)
35npm install -g cannbot-proxy # cpx 捕获代理(cpx)35npm install -g cannbot-proxy # cpx 捕获代理(cpx)
36+# Context 品牌同款(会话数据完全互通):
37+npm install -g context-insight # insight 观测平台(context-insight / ctxi)
38+npm install -g context-cpx # cpx 捕获代理(cpx)
36```39```
37 40 
38Tab 补全在安装时自动装配(bash/zsh),新开终端 `cpx <TAB>` 即可用。41Tab 补全在安装时自动装配(bash/zsh),新开终端 `cpx <TAB>` 即可用。
@@ -41,11 +44,11 @@ Tab 补全在安装时自动装配(bash/zsh),新开终端 `cpx <TAB>` 即
41 44 
42```bash45```bash
43./install.sh insight # 安装单个包46./install.sh insight # 安装单个包
44-./install.sh cpx sift # 组合安装47+./install.sh insight cpx # 组合安装
45./install.sh all # 全量安装48./install.sh all # 全量安装
46```49```
47 50 
48-> 安装脚本将随 packages/ 各包迁入逐步完全可用,各包前置要求见其 README。51+> insight 与 cpx 现已可安装;sift 待迁入暂为占位。各包前置要求见其 README。
49 52 
50## 目录结构53## 目录结构
51 54 
@@ -6,7 +6,7 @@
6 6 
7CANNBot-Sentry is the sentry post of **Observation · Audit · Evaluation** for LLM coding agents (opencode / Claude Code). While agents run on the field, Sentry stands on the sideline: **recording what happened, replaying the facts, and judging the quality**.7CANNBot-Sentry is the sentry post of **Observation · Audit · Evaluation** for LLM coding agents (opencode / Claude Code). While agents run on the field, Sentry stands on the sideline: **recording what happened, replaying the facts, and judging the quality**.
8 8 
9-> Status: this repository is newly created. Packages are being migrated from [cannbot-skills](https://gitcode.com/cann/cannbot-skills) (`plugins-community/cannbot-insight`) into `packages/insight` and `packages/cpx`. See [Directory Structure](#directory-structure).9+> Status: `packages/insight` and `packages/cpx` have landed and are published on npm; `packages/sift` (Audit / Evaluate) is planned and not yet migrated. See [Directory Structure](#directory-structure).
10 10 
11## Core Capabilities11## Core Capabilities
12 12 
@@ -33,6 +33,9 @@ cd cannbot-sentry
33```bash33```bash
34npm install -g cannbot-insight # insight Web UI (cannbot-insight / cbi)34npm install -g cannbot-insight # insight Web UI (cannbot-insight / cbi)
35npm install -g cannbot-proxy # cpx capture proxy (cpx)35npm install -g cannbot-proxy # cpx capture proxy (cpx)
36+# Same tools under the Context brand (session data fully interops):
37+npm install -g context-insight # insight Web UI (context-insight / ctxi)
38+npm install -g context-cpx # cpx capture proxy (cpx)
36```39```
37 40 
38Shell tab completion is configured automatically on install (bash/zsh); open a new terminal and `cpx <TAB>` just works.41Shell tab completion is configured automatically on install (bash/zsh); open a new terminal and `cpx <TAB>` just works.
@@ -41,11 +44,11 @@ Shell tab completion is configured automatically on install (bash/zsh); open a n
41 44 
42```bash45```bash
43./install.sh insight # single package46./install.sh insight # single package
44-./install.sh cpx sift # combination47+./install.sh insight cpx # combination
45./install.sh all # everything48./install.sh all # everything
46```49```
47 50 
48-> The installer becomes fully functional as packages land in `packages/`. See each package's README for per-package requirements.51+> `insight` and `cpx` are installable now; `sift` stays a placeholder until it lands. See each package's README for per-package requirements.
49 52 
50## Directory Structure53## Directory Structure
51 54 
@@ -9,7 +9,7 @@
9| 架构设计 | Monorepo 一仓一包架构、包间运行时契约、零侵入原则 | 规划中 |9| 架构设计 | Monorepo 一仓一包架构、包间运行时契约、零侵入原则 | 规划中 |
10| 安装指南 | 统一安装(单包 / 组合 / all)与各包前置要求 | 规划中 |10| 安装指南 | 统一安装(单包 / 组合 / all)与各包前置要求 | 规划中 |
11 11 
12-> 本仓库处于建仓初期:`packages/insight` 与 `packages/cpx` 正在从 [cannbot-skills](https://gitcode.com/cann/cannbot-skills) 的 `plugins-community/cannbot-insight` 迁入,`packages/sift` 待迁入。文档将随代码迁移逐步补齐。12+> `packages/insight` 与 `packages/cpx` 已从 [cannbot-skills](https://gitcode.com/cann/cannbot-skills)(`plugins-community/cannbot-insight`)迁入落地并发布 npm;`packages/sift` 待迁入。文档将随各包落地逐步补齐。
13 13 
14## 相关链接14## 相关链接
15 15 
@@ -2,6 +2,13 @@
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+## [v0.1.4] - 2026-09-16
6+ 
7+### Added
8+- 【双名发布:context 品牌变体(`context-cpx`,bin 保持 `cpx`)】同一源码仓、打包期品牌变换——`scripts/pack-context.mjs`(机制与 insight 侧同构:dry-run 取清单 → staging 精确字面量替换(每规则 minHits 哨兵)→ staging 内 `npm pack`)。翻转面:捕获/缓存目录默认值 `~/.context-insight/proxy` 与 `~/.context-insight/cannbay-cache-cpx`(**与 context-insight 的数据目录配对**——其导入对话框默认路径才能命中)、`cpx status` 标签、**Bay 显示名(`CANNBay`/`CANNbay` 两种大小写形态均提进通用规则**——此前 `CANNBay` 只挂在 MD_RULES,src 里帮助文本/日志硬编码的大写 B 形态随白名单进成品不被替换;cpx 源码无仓 URL,可全局替换)与文档包名;保留面:`x_cannbay` 格式契约、`CANNBOT_PROXY_DIR`/`CANNBAY2_*` env 名、`cpx cannbot` 的 cannbot CLI 命令(外部产品)。注意与 cannbot-proxy 同 bin 名(`cpx`),同机共装冲突——同一工具无需双装。发布:`node scripts/pack-context.mjs` → `npm publish .context-pack/context-cpx-<ver>.tgz`。IT:`tests/context-pack.test.ts`(package.json 断言 + 目录默认值翻转断言 + 品牌泄漏哨兵(**覆盖 `CANNbay`/`CANNBay` 两种形态**)+ 契约保留断言;cpx 无构建步骤,静态断言即运行时断言)
9+- 【CANNBay 上传 LFS 模式(opt-in `CANNBAY2_LFS=1`)——容量脱离 git 仓配额】数据仓启用 LFS 后 `.jsonl.gz` 以 LFS 指针出仓(仓内 blob=指针文本,实体进 LFS 存储),数据量不再计入 atomgit git_repo 1GiB 硬配额(压测实测:普通 blob 2.1GiB 触顶硬拒,LFS 通道独立存储不受此限)。管线其余不变:治理/范式目录/baseline 交换/幂等 unchanged 全部照旧(幂等在指针级判定,clean 对指针文本透传——baseline 搬移的 re-add 不重传)。默认关闭(不设 env = 既有 LFS 免疫行为逐字节不变)。实现要点:镜像内手工写仓级 `filter.lfs.*` 配置 + pre-push 钩子(不调 `git lfs install`,不碰用户全局配置);LFS 模式 add/push 跳过 filter 中和、push 不带 `--no-verify`(钩子=LFS 对象上传通道,跳过即孤儿指针);`.gitattributes` 反向操作——确保 `*.gz filter=lfs` 声明存在(默认模式仍是剔除);git-lfs 不在机 fail-fast 报安装指引。配套 `CANNBAY2_MAX_SESSION_MB` 覆写单会话大小限(默认 100MB=git blob 平台钳制;LFS 模式下可放宽——限制转移至 LFS 对象侧)。IT:本地 `file://` bare + git-lfs standalone 传输全链路(指针/实体/幂等/限值,`tests/cannbay-lfs.test.ts`,无 git-lfs 机器自动跳过)
10+- 【治理快路径修复:`governed=true` 标记从未生效】`govern.ts` 把 `<sid>.jsonl.gz` 路径直接喂给只读纯 JSON 的 `readCcSessionMeta`(meta 实为 `<sid>.meta.json`)——快路径条件永假,所有 cpx 直传都走慢路径(解压→逐行清洗→重压,82MB 会话 ~38s 大半耗在此)。修复路径换算与 insight 侧同构(`.jsonl(.gz)` → `.meta.json`),带 `governed=true` 标记的压缩捕获恢复免解压直传(字节级信任,落盘 redactor 已全量清洗)
11+ 
5## [v0.1.3] - 2026-09-1412## [v0.1.3] - 2026-09-14
6 13 
7### Changed14### Changed
Mpackages/cpx/README.md+15-1文件内容可能有违规信息
@@ -1,12 +1,12 @@
1{1{
2 "name": "cannbot-proxy",2 "name": "cannbot-proxy",
3- "version": "0.1.3",3+ "version": "0.1.4",
4 "lockfileVersion": 3,4 "lockfileVersion": 3,
5 "requires": true,5 "requires": true,
6 "packages": {6 "packages": {
7 "": {7 "": {
8 "name": "cannbot-proxy",8 "name": "cannbot-proxy",
9- "version": "0.1.3",9+ "version": "0.1.4",
10 "dependencies": {10 "dependencies": {
11 "fzstd": "^0.1.1",11 "fzstd": "^0.1.1",
12 "tsx": "^4.22.4",12 "tsx": "^4.22.4",
@@ -1,6 +1,6 @@
1{1{
2 "name": "cannbot-proxy",2 "name": "cannbot-proxy",
3- "version": "0.1.3",3+ "version": "0.1.4",
4 "type": "module",4 "type": "module",
5 "description": "Generic agent<->model plaintext capture proxy for CANNBot-Insight",5 "description": "Generic agent<->model plaintext capture proxy for CANNBot-Insight",
6 "license": "SEE LICENSE IN LICENSE",6 "license": "SEE LICENSE IN LICENSE",
@@ -0,0 +1,148 @@
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+// context 品牌变体打包(npm pack):
10+// node scripts/pack-context.mjs → .context-pack/context-cpx-<version>.tgz
11+// 发布:npm publish .context-pack/context-cpx-<version>.tgz
12+//
13+// 机制:单源码仓 + 打包期变换 —— `npm pack --dry-run` 取本包真实清单,复制到
14+// staging 后做精确字面量替换(规则表见下),staging 内 `npm pack`。源码仓零改动。
15+// bin 保持 `cpx`(中性名):context-cpx 与 cannbot-proxy 提供同名命令,不能同机
16+// 共装——同一工具无需双装,代价可接受。
17+//
18+// 品牌边界:
19+// 翻转 —— 捕获/缓存目录默认值(~/.context-insight/proxy,与 context-insight
20+// 的数据目录配对,导入对话框默认路径才能命中)/ 状态标签 / 文档
21+// 保留 —— x_cannbay 命名空间(格式契约)/ CANNBOT_* 与 CANNBAY2_* 环境变量名 /
22+// `cpx cannbot` 的 cannbot CLI 命令 / git 机器提交身份
23+//
24+// 每条规则带 minHits(防漂移哨兵):源码重构后若必命中的规则命中 0 次,打包报错。
25+// tests/context-pack.test.ts 对产物做品牌泄漏扫描。
26+ 
27+import { spawnSync } from 'node:child_process'
28+import fs from 'node:fs'
29+import path from 'node:path'
30+import { fileURLToPath } from 'node:url'
31+ 
32+const PKG_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
33+const STAGING = path.join(PKG_DIR, '.context-pack', 'pkg')
34+const OUT_DIR = path.join(PKG_DIR, '.context-pack')
35+ 
36+// 不进 context 变体包:打包脚本自身(规则串自引用,会误触品牌泄漏哨兵)
37+const EXCLUDE = new Set(['scripts/pack-context.mjs'])
38+ 
39+// 通用规则(全部文本文件;精确字符串替换,非正则)
40+const GLOBAL_RULES = [
41+ { find: "'.cannbot-insight'", replace: "'.context-insight'", minHits: 5 },
42+ { find: '~/.cannbot-insight', replace: '~/.context-insight', minHits: 3 },
43+ { find: 'cannbot-insight :', replace: 'context-insight :', minHits: 1 },
44+ { find: 'cannbot-insight UI', replace: 'context-insight UI', minHits: 1 },
45+ // Bay 显示名两种大小写形态都进通用层:src 里帮助文本/日志硬编码大写 B 的
46+ // CANNBay(cpx-cli.ts / cpx-upload.ts / cannbay/index.ts),md 里两种混用;
47+ // cpx 源码无 CANNBay 形态的仓 URL(insight v1 路由才有),可全局替换
48+ { find: 'CANNBay', replace: 'contextBay', minHits: 1 },
49+ { find: 'CANNbay', replace: 'contextBay', minHits: 0 },
50+]
51+ 
52+// 文档规则(*.md:README / docs —— 面向 context-cpx 用户)
53+const MD_RULES = [
54+ { find: 'cannbot-proxy', replace: 'context-cpx', minHits: 1 },
55+ { find: 'cannbot-insight', replace: 'context-insight', minHits: 1 },
56+]
57+ 
58+const TEXT_EXTS = new Set([
59+ '.ts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.py',
60+ '.css', '.scss', '.html', '.txt', '.sh', '.yml', '.yaml', '.sql',
61+ '.prisma', '.xml', '.svg', '.example',
62+])
63+ 
64+function rewritePkgJson(pkg) {
65+ pkg.name = 'context-cpx'
66+ pkg.description = 'Generic agent<->model plaintext capture proxy for Context-Insight'
67+ pkg.keywords = ['cpx', 'context-cpx', 'context-insight', 'proxy', 'capture', 'llm', 'agent']
68+ return pkg
69+}
70+ 
71+function packFileList() {
72+ const r = spawnSync('npm', ['pack', '--dry-run', '--json'], {
73+ cwd: PKG_DIR, encoding: 'utf8', timeout: 120_000, maxBuffer: 32 * 1024 * 1024,
74+ })
75+ if (r.status !== 0) throw new Error(`npm pack --dry-run 失败:\n${r.stderr}`)
76+ const parsed = JSON.parse(r.stdout)
77+ return parsed[0].files.map((f) => f.path)
78+}
79+ 
80+function copyTree(files) {
81+ fs.rmSync(path.join(PKG_DIR, '.context-pack'), { recursive: true, force: true })
82+ fs.mkdirSync(STAGING, { recursive: true })
83+ for (const rel of files) {
84+ if (EXCLUDE.has(rel)) continue
85+ // 防御:上次运行的 .context-pack 产物若混入清单,rm -rf 后源已不存在 —— 一律跳过
86+ if (rel === '.context-pack' || rel.startsWith('.context-pack/')) continue
87+ const src = path.join(PKG_DIR, rel)
88+ const dst = path.join(STAGING, rel)
89+ fs.mkdirSync(path.dirname(dst), { recursive: true })
90+ fs.copyFileSync(src, dst)
91+ }
92+}
93+ 
94+function transformTree() {
95+ const hits = new Map()
96+ for (const rule of [...GLOBAL_RULES, ...MD_RULES]) hits.set(rule.find, 0)
97+ const walk = (dir) => {
98+ for (const name of fs.readdirSync(dir)) {
99+ const full = path.join(dir, name)
100+ const stat = fs.statSync(full)
101+ if (stat.isDirectory()) { walk(full); continue }
102+ const rel = path.relative(STAGING, full)
103+ if (rel === 'package.json') continue
104+ if (!TEXT_EXTS.has(path.extname(name))) continue
105+ let text = fs.readFileSync(full, 'utf8')
106+ const rules = path.extname(name) === '.md' ? [...GLOBAL_RULES, ...MD_RULES] : GLOBAL_RULES
107+ for (const rule of rules) {
108+ const n = text.split(rule.find).length - 1
109+ if (n > 0) { text = text.split(rule.find).join(rule.replace); hits.set(rule.find, hits.get(rule.find) + n) }
110+ }
111+ if (text !== fs.readFileSync(full, 'utf8')) fs.writeFileSync(full, text)
112+ }
113+ }
114+ walk(STAGING)
115+ 
116+ const pkgPath = path.join(STAGING, 'package.json')
117+ const pkg = rewritePkgJson(JSON.parse(fs.readFileSync(pkgPath, 'utf8')))
118+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
119+ 
120+ for (const rule of [...GLOBAL_RULES, ...MD_RULES]) {
121+ if ((hits.get(rule.find) ?? 0) < rule.minHits) {
122+ throw new Error(
123+ `规则 "${rule.find}" 命中 ${hits.get(rule.find) ?? 0} 次 < 最低 ${rule.minHits} —— ` +
124+ '源码品牌字面量可能已漂移,请核对 scripts/pack-context.mjs 规则表',
125+ )
126+ }
127+ }
128+ return hits
129+}
130+ 
131+function main() {
132+ const files = packFileList()
133+ copyTree(files)
134+ const hits = transformTree()
135+ 
136+ const r = spawnSync('npm', ['pack', '--json', '--pack-destination', OUT_DIR], {
137+ cwd: STAGING, encoding: 'utf8', timeout: 180_000, maxBuffer: 32 * 1024 * 1024,
138+ })
139+ if (r.status !== 0) throw new Error(`staging npm pack 失败:\n${r.stderr}`)
140+ const tgzName = JSON.parse(r.stdout)[0].filename
141+ 
142+ console.log(`[pack-context] 规则命中统计:`)
143+ for (const [find, n] of hits) if (n > 0) console.log(` ${n}× ${find}`)
144+ console.log(`[pack-context] 产物:${path.join(OUT_DIR, tgzName)}`)
145+ console.log(`[pack-context] 发布:npm publish ${path.join(OUT_DIR, tgzName)}`)
146+}
147+ 
148+main()
@@ -23,6 +23,14 @@ import { redactString, redactInPlace, matchStringRules, isSensitiveKeyName, look
23 23 
24export const MAX_SESSION_BYTES = 100 * 1024 * 1024;24export const MAX_SESSION_BYTES = 100 * 1024 * 1024;
25 25 
26+/** 单会话大小上限(gzip 后字节):默认 100MB,env CANNBAY2_MAX_SESSION_MB
27+ * 可覆写(LFS 模式下平台限制在 LFS 对象侧,git blob 的 100MiB 钳制不再
28+ * 适用;普通模式勿动——atomgit pre-receive 硬拒 100MiB 以上 blob)。 */
29+export function maxSessionBytes(): number {
30+ const v = Number(process.env.CANNBAY2_MAX_SESSION_MB);
31+ return Number.isFinite(v) && v > 0 ? Math.floor(v * 1024 * 1024) : MAX_SESSION_BYTES;
32+}
33+ 
26export class GovernanceError extends Error {}34export class GovernanceError extends Error {}
27 35 
28interface CcSessionMeta {36interface CcSessionMeta {
@@ -113,14 +121,17 @@ export function governStagedSession(stagingDir: string, sid: string): void {
113 throw new GovernanceError(`上传熔断:staging 存在白名单外文件(仅允许 .jsonl/.jsonl.gz/.json):${unexpected.join(', ')}`);121 throw new GovernanceError(`上传熔断:staging 存在白名单外文件(仅允许 .jsonl/.jsonl.gz/.json):${unexpected.join(', ')}`);
114 }122 }
115 const jsonlFiles = files.filter(f => f.endsWith('.jsonl') || f.endsWith('.jsonl.gz'));123 const jsonlFiles = files.filter(f => f.endsWith('.jsonl') || f.endsWith('.jsonl.gz'));
116- const meta = readCcSessionMeta(path.join(sessionDir, `${sid}.jsonl.gz`));124+ // meta 伴生文件是 <sid>.meta.json(staging 必备)——此前误传 <sid>.jsonl.gz
125+ // (gzip 二进制 JSON.parse 必败 → 快路径永假,governed 信任标记从未生效,
126+ // 全量走慢路径解压重洗重压)
127+ const meta = readCcSessionMeta(path.join(sessionDir, `${sid}.meta.json`));
117 const fastPath = meta?.producer === 'cpx' && meta.governed === true128 const fastPath = meta?.producer === 'cpx' && meta.governed === true
118 && jsonlFiles.length > 0 && jsonlFiles.every(f => f.endsWith('.jsonl.gz'));129 && jsonlFiles.length > 0 && jsonlFiles.every(f => f.endsWith('.jsonl.gz'));
119 if (fastPath) {130 if (fastPath) {
120 let totalBytes = 0;131 let totalBytes = 0;
121 for (const rel of jsonlFiles) totalBytes += fs.statSync(path.join(sessionDir, rel)).size;132 for (const rel of jsonlFiles) totalBytes += fs.statSync(path.join(sessionDir, rel)).size;
122- if (totalBytes > MAX_SESSION_BYTES) {133+ if (totalBytes > maxSessionBytes()) {
123- throw new GovernanceError(`Session "${sid}" exceeds single-file size limit (${(totalBytes / 1024 / 1024).toFixed(1)}MB > 100MB)`);134+ throw new GovernanceError(`Session "${sid}" exceeds single-file size limit (${(totalBytes / 1024 / 1024).toFixed(1)}MB > ${(maxSessionBytes() / 1024 / 1024).toFixed(0)}MB)`);
124 }135 }
125 return;136 return;
126 }137 }
@@ -147,7 +158,7 @@ export function governStagedSession(stagingDir: string, sid: string): void {
147 fs.rmSync(file);158 fs.rmSync(file);
148 totalBytes += fs.statSync(gzFile).size;159 totalBytes += fs.statSync(gzFile).size;
149 }160 }
150- if (totalBytes > MAX_SESSION_BYTES) {161+ if (totalBytes > maxSessionBytes()) {
151- throw new GovernanceError(`Session "${sid}" exceeds single-file size limit (${(totalBytes / 1024 / 1024).toFixed(1)}MB > 100MB,gzip 压缩后仍超限)`);162+ throw new GovernanceError(`Session "${sid}" exceeds single-file size limit (${(totalBytes / 1024 / 1024).toFixed(1)}MB > ${(maxSessionBytes() / 1024 / 1024).toFixed(0)}MB,gzip 压缩后仍超限)`);
152 }163 }
153}164}
@@ -13,9 +13,10 @@
13// strip、幂等 unchanged)——这里的 git 序列漂移即测试失败。归档轮转13// strip、幂等 unchanged)——这里的 git 序列漂移即测试失败。归档轮转
14// (cannbay-archive planArchive)insight cannbay2 上传路径本就未接线,不移植。14// (cannbay-archive planArchive)insight cannbay2 上传路径本就未接线,不移植。
15 15 
16-import { execSync } from 'node:child_process';16+import { execSync, spawnSync } from 'node:child_process';
17import { randomUUID } from 'node:crypto';17import { randomUUID } from 'node:crypto';
18import fs from 'node:fs';18import fs from 'node:fs';
19+import os from 'node:os';
19import path from 'node:path';20import path from 'node:path';
20import {21import {
21 resolveCannbayRemote, stripUrlCreds, escapeRegExp, type CannbayRemote,22 resolveCannbayRemote, stripUrlCreds, escapeRegExp, type CannbayRemote,
@@ -27,8 +28,11 @@ import {
27// 操作对"装没装 git-lfs"完全不敏感28// 操作对"装没装 git-lfs"完全不敏感
28export const LFS_NEUTRALIZE = '-c filter.lfs.process= -c filter.lfs.smudge=cat -c filter.lfs.clean=cat -c filter.lfs.required=false';29export const LFS_NEUTRALIZE = '-c filter.lfs.process= -c filter.lfs.smudge=cat -c filter.lfs.clean=cat -c filter.lfs.required=false';
29 30 
30-export function runGit(cmd: string, cwd: string, timeoutMs = 120_000): Buffer {31+// raw = 跳过中和(LFS 模式的 add/push 专用:clean 过滤器要真跑出指针、
31- const neutral = cmd.startsWith('git ')32+// pre-push 钩子要真上传 LFS 对象);其余命令保持中和(checkout 的 smudge=cat
33+// 让既有 LFS 文件落指针文本——clean 对指针透传,re-add 不炸不重传)
34+export function runGit(cmd: string, cwd: string, timeoutMs = 120_000, opts?: { raw?: boolean }): Buffer {
35+ const neutral = !opts?.raw && cmd.startsWith('git ')
32 ? `git ${LFS_NEUTRALIZE} ${cmd.slice('git '.length)}`36 ? `git ${LFS_NEUTRALIZE} ${cmd.slice('git '.length)}`
33 : cmd;37 : cmd;
34 return execSync(neutral, { cwd, stdio: 'pipe', timeout: timeoutMs, maxBuffer: 256 * 1024 * 1024 });38 return execSync(neutral, { cwd, stdio: 'pipe', timeout: timeoutMs, maxBuffer: 256 * 1024 * 1024 });
@@ -208,6 +212,52 @@ async function withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
208 }212 }
209}213}
210 214 
215+// ── LFS 模式(opt-in:remote.lfs,env CANNBAY2_LFS)─────────────────────
216+ 
217+export function hasGitLfs(): boolean {
218+ try {
219+ const r = spawnSync('git', ['lfs', 'version'], { stdio: 'pipe', timeout: 10_000 });
220+ return r.status === 0;
221+ } catch {
222+ return false;
223+ }
224+}
225+ 
226+// git-lfs pre-push 钩子(与 `git lfs install` 写入的同款):push 时上传
227+// LFS 对象。手工写入而非调 `git lfs install`——后者会顺带改全局 gitconfig,
228+// 库代码不该碰用户全局配置。
229+const LFS_PRE_PUSH_HOOK = '#!/bin/sh\ncommand -v git-lfs >/dev/null 2>&1 || { echo "git-lfs not installed"; exit 2; }\ngit lfs pre-push "$@"\n';
230+ 
231+/** 镜像内 LFS 就绪:fail-fast git-lfs 在机检查 + 仓级 filter 配置 + pre-push
232+ * 钩子。幂等;不触碰全局配置(仓级配置优先级高于全局,双保险)。 */
233+export function ensureLfsReady(dir: string): void {
234+ if (!hasGitLfs()) {
235+ throw new Error('LFS 模式需要 git-lfs(`git lfs version` 不可用):请安装 git-lfs 并确保其在 PATH 中(https://git-lfs.github.com),或去掉 CANNBAY2_LFS 走普通 git 存储');
236+ }
237+ runGit('git config filter.lfs.process "git-lfs filter-process"', dir, 10_000, { raw: true });
238+ runGit('git config filter.lfs.smudge "git-lfs smudge -- %f"', dir, 10_000, { raw: true });
239+ runGit('git config filter.lfs.clean "git-lfs clean -- %f"', dir, 10_000, { raw: true });
240+ runGit('git config filter.lfs.required true', dir, 10_000, { raw: true });
241+ const hook = path.join(dir, '.git', 'hooks', 'pre-push');
242+ if (!fs.existsSync(hook)) {
243+ fs.mkdirSync(path.dirname(hook), { recursive: true });
244+ fs.writeFileSync(hook, LFS_PRE_PUSH_HOOK, { mode: 0o755 });
245+ }
246+}
247+ 
248+// LFS 模式下确保 .gitattributes 声明 *.gz 走 LFS(缺行补一行,与默认模式
249+// 的"剔除 lfs 行"互为镜像操作;模板仓自带该行则零改动)
250+function ensureLfsGitattributes(dir: string): void {
251+ const gaPath = path.join(dir, '.gitattributes');
252+ const want = '*.gz filter=lfs diff=lfs merge=lfs -text';
253+ let cur = '';
254+ try { cur = fs.readFileSync(gaPath, 'utf8'); } catch { /* 无文件 */ }
255+ if (!/^\*\.gz\s+filter=lfs/m.test(cur)) {
256+ fs.writeFileSync(gaPath, cur ? cur.replace(/\n*$/, '\n') + want + '\n' : `${want}\n`);
257+ runGit('git add .gitattributes', dir);
258+ }
259+}
260+ 
211export interface UploadResult {261export interface UploadResult {
212 folder: string;262 folder: string;
213 unchanged: boolean;263 unchanged: boolean;
@@ -229,6 +279,8 @@ export async function uploadFolder(
229 const targetRoot = category ?? remote.rootDir;279 const targetRoot = category ?? remote.rootDir;
230 const dir = ensureMirror(remote);280 const dir = ensureMirror(remote);
231 if (!revParseMain(dir, remote)) throw new Error('cannbay mirror has no main branch');281 if (!revParseMain(dir, remote)) throw new Error('cannbay mirror has no main branch');
282+ // LFS 模式:镜像内就绪(git-lfs 检查 + filter 配置 + pre-push 钩子)
283+ if (remote.lfs) ensureLfsReady(dir);
232 284 
233 const staleLocs = locateSessionLocations(dir, remote, sid)285 const staleLocs = locateSessionLocations(dir, remote, sid)
234 .filter(l => !(l.root === targetRoot && l.baseline === !!baseline));286 .filter(l => !(l.root === targetRoot && l.baseline === !!baseline));
@@ -264,7 +316,10 @@ export async function uploadFolder(
264 316 
265 let gitattributesFixed = false;317 let gitattributesFixed = false;
266 const gaPath = path.join(dir, '.gitattributes');318 const gaPath = path.join(dir, '.gitattributes');
267- if (fs.existsSync(gaPath)) {319+ if (remote.lfs) {
320+ // LFS 模式:反向操作——确保 *.gz 声明走 LFS(缺则补),绝不剔除
321+ ensureLfsGitattributes(dir);
322+ } else if (fs.existsSync(gaPath)) {
268 const ga = fs.readFileSync(gaPath, 'utf8');323 const ga = fs.readFileSync(gaPath, 'utf8');
269 const fixed = ga.split('\n').filter(l => !l.includes('filter=lfs')).join('\n');324 const fixed = ga.split('\n').filter(l => !l.includes('filter=lfs')).join('\n');
270 if (fixed !== ga) {325 if (fixed !== ga) {
@@ -275,7 +330,9 @@ export async function uploadFolder(
275 }330 }
276 331 
277 const addPaths = [targetRel, ...staleLocs.map(l => locationPath(l, sid)), ...swapPaths, ...(baseline ? [`${targetRoot}/baseline`] : [])];332 const addPaths = [targetRel, ...staleLocs.map(l => locationPath(l, sid)), ...swapPaths, ...(baseline ? [`${targetRoot}/baseline`] : [])];
278- runGit(`git add ${addPaths.join(' ')}`, dir);333+ // LFS 模式 add 不中和:clean 过滤器要把 .gz 转成 LFS 指针(对指针文本
334+ // 透传,baseline 搬移的 re-add 不重传)
335+ runGit(`git add ${addPaths.join(' ')}`, dir, 120_000, remote.lfs ? { raw: true } : undefined);
279 const msgFile = path.join(dir, '_commit_msg.txt');336 const msgFile = path.join(dir, '_commit_msg.txt');
280 fs.writeFileSync(msgFile, commitMessage);337 fs.writeFileSync(msgFile, commitMessage);
281 let unchanged = false;338 let unchanged = false;
@@ -292,7 +349,7 @@ export async function uploadFolder(
292 try { fs.unlinkSync(msgFile); } catch { /* ignore */ }349 try { fs.unlinkSync(msgFile); } catch { /* ignore */ }
293 350 
294 if (!unchanged) {351 if (!unchanged) {
295- const push = () => { runGit(`git push --no-verify "${remote.pushUrl}" ${remote.branch}`, dir, 300_000); };352+ const push = () => { pushMirror(dir, remote); };
296 try {353 try {
297 push();354 push();
298 } catch {355 } catch {
@@ -304,3 +361,44 @@ export async function uploadFolder(
304 return { folder: `${targetRel}/`, unchanged, gitattributesFixed };361 return { folder: `${targetRel}/`, unchanged, gitattributesFixed };
305 });362 });
306}363}
364+ 
365+/**
366+ * 401 挑战-应答式 push:pushUrl 一律无凭据,凭据经一次性 credential helper
367+ * 供给——atomgit 网关已拒"请求直带密码"的内嵌 URL(proactive Basic 直接
368+ * CH.00905401 拒绝;同凭据经 401 挑战后重试可通过,2026-09-14 实测)。
369+ * helper 脚本只落 0700 临时目录、用后即删,凭据不进命令行(execSync 失败
370+ * 消息不含凭据)。`-c credential.helper=` 先清空继承链(全局 store 等),
371+ * 保证显式凭据确定性生效。pushAuth=null(本地 bare / 匿名推)直推。
372+ * LFS 模式:不用 --no-verify(pre-push 钩子 = LFS 对象上传通道,跳过即
373+ * 产生孤儿指针);helper 的 -c 配置经 GIT_CONFIG_PARAMETERS 传给钩子,
374+ * git-lfs 的 batch API 鉴权与 git push 同源。
375+ */
376+export function pushMirror(dir: string, remote: CannbayRemote): void {
377+ const verify = remote.lfs ? '' : ' --no-verify';
378+ const raw = remote.lfs ? { raw: true as const } : undefined;
379+ if (!remote.pushAuth) {
380+ runGit(`git push${verify} "${remote.pushUrl}" ${remote.branch}`, dir, 300_000, raw);
381+ return;
382+ }
383+ const { username, password } = remote.pushAuth;
384+ if (/[\r\n]/.test(username) || /[\r\n]/.test(password)) {
385+ throw new Error('push 凭据含换行符(credential helper 协议非法)');
386+ }
387+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cpx-cannbay-auth-'));
388+ try {
389+ const helper = path.join(tmp, 'helper.sh');
390+ fs.writeFileSync(
391+ helper,
392+ `#!/bin/sh\ncat <<'CPXEOF'\nusername=${username}\npassword=${password}\nCPXEOF\n`,
393+ { mode: 0o700 },
394+ );
395+ runGit(
396+ `git -c credential.helper= -c credential.helper="${helper}" push${verify} "${remote.pushUrl}" ${remote.branch}`,
397+ dir,
398+ 300_000,
399+ raw,
400+ );
401+ } finally {
402+ fs.rmSync(tmp, { recursive: true, force: true });
403+ }
404+}
@@ -24,7 +24,10 @@ export function envPullUrl(): string {
24 return process.env.CANNBAY2_REMOTE_URL ?? 'https://atomgit.com/duanbingzhe/cannbay3.git';24 return process.env.CANNBAY2_REMOTE_URL ?? 'https://atomgit.com/duanbingzhe/cannbay3.git';
25}25}
26 26 
27-// 密文常量(与 insight cannbay2/remote.ts 同一枚,parity 测试锁同值)27+// 密文常量(与 insight cannbay2/remote.ts 同一枚,parity 测试锁同值)——
28+// 解码 = https://<user>:<token>@atomgit.com/…。它是 push 凭据源(auth 提取),
29+// 不再内嵌进 push URL:atomgit 已拒"请求直带密码"式鉴权,凭据只经
30+// credential helper 的 401 挑战-应答通道供给(见 mirror.ts pushMirror)。
28const CANNBAY2_PUSH_URL_ENCODED = 'aHR0cHM6Ly9kdWFuYmluZ3poZTpvU1I1aTF3WXZ5dlZpUmdMbXdna3NDeG5AYXRvbWdpdC5jb20vZHVhbmJpbmd6aGUvY2FubmJheTMuZ2l0';31const CANNBAY2_PUSH_URL_ENCODED = 'aHR0cHM6Ly9kdWFuYmluZ3poZTpvU1I1aTF3WXZ5dlZpUmdMbXdna3NDeG5AYXRvbWdpdC5jb20vZHVhbmJpbmd6aGUvY2FubmJheTMuZ2l0';
29 32 
30export function envPushUrl(): string {33export function envPushUrl(): string {
@@ -35,13 +38,42 @@ export function envBranch(): string {
35 return process.env.CANNBAY2_BRANCH ?? 'main';38 return process.env.CANNBAY2_BRANCH ?? 'main';
36}39}
37 40 
41+// LFS 模式(惰性读,与 env 三件套同款):.jsonl.gz 以 LFS 指针出仓,实体
42+// 进 LFS 存储——数据量不再计入 git 仓配额(atomgit git_repo 1GiB 硬限的
43+// 出路)。要求客户机装 git-lfs(mirror.ts ensureLfsReady fail-fast)。
44+export function envLfs(): boolean {
45+ const v = process.env.CANNBAY2_LFS;
46+ return v === '1' || v === 'true' || v === 'on';
47+}
48+ 
49+export interface PushAuth {
50+ username: string;
51+ password: string;
52+}
53+ 
54+/** https URL 内嵌凭据拆分:`scheme://user:pass@rest` → 无凭据 URL + auth 对;
55+ * 无凭据(本地路径 / 匿名 URL)原样返回、auth=null。 */
56+export function parseUrlCreds(url: string): { credless: string; auth: PushAuth | null } {
57+ const m = url.match(/^(https?:\/\/)([^/@:\s]+):([^@\s]+)@/i);
58+ if (!m) return { credless: url, auth: null };
59+ return {
60+ credless: url.slice(0, m[1].length) + url.slice(m[0].length),
61+ auth: { username: decodeURIComponent(m[2]), password: decodeURIComponent(m[3]) },
62+ };
63+}
64+ 
38export interface CannbayRemote {65export interface CannbayRemote {
39 pullUrl: string;66 pullUrl: string;
67+ /** 无凭据 push 地址:凭据走 credential helper(pushAuth),绝不内嵌 URL */
40 pushUrl: string;68 pushUrl: string;
69+ /** push 凭据(内置共享令牌 / env push URL 内嵌凭据解析而来);null = 匿名推(本地 bare 等) */
70+ pushAuth: PushAuth | null;
41 branch: string;71 branch: string;
42 rootDir: string;72 rootDir: string;
43 cacheDir: string;73 cacheDir: string;
44 paradigmRoots: boolean;74 paradigmRoots: boolean;
75+ /** LFS 模式:.jsonl.gz 走 LFS 指针(env CANNBAY2_LFS;缺省 = 既有免疫行为) */
76+ lfs?: boolean;
45}77}
46 78 
47const FORBIDDEN_ADDRESS_CHARS = /["'`$;&|\n\r]/;79const FORBIDDEN_ADDRESS_CHARS = /["'`$;&|\n\r]/;
@@ -95,20 +127,23 @@ export function resolveCannbayRemote(): CannbayRemote {
95 defaultCacheDir(),127 defaultCacheDir(),
96 createHash('sha1').update(`${stripUrlCreds(credlessPull)}|${branch}`).digest('hex').slice(0, 12),128 createHash('sha1').update(`${stripUrlCreds(credlessPull)}|${branch}`).digest('hex').slice(0, 12),
97 );129 );
130+ const pushSource = envPushUrl().trim();
131+ if (FORBIDDEN_ADDRESS_CHARS.test(pushSource)) throw new Error('数据湖 push 地址含非法字符');
132+ const { credless: pushUrl, auth: pushAuth } = parseUrlCreds(pushSource);
98 return {133 return {
99 pullUrl: credlessPull,134 pullUrl: credlessPull,
100- pushUrl: envPushUrl(),135+ pushUrl,
136+ pushAuth,
101 branch,137 branch,
102 rootDir: 'sessions',138 rootDir: 'sessions',
103 cacheDir,139 cacheDir,
104 paradigmRoots: true,140 paradigmRoots: true,
141+ lfs: envLfs(),
105 };142 };
106}143}
107 144 
108export function redactRemoteSecrets(message: string): string {145export function redactRemoteSecrets(message: string): string {
109- return message146+ return message.replace(/https:\/\/[^/\s"']+@/g, 'https://<creds>@');
110- .replace(envPushUrl(), '<push-url>')
111- .replace(/https:\/\/[^/\s"']+@/g, 'https://<creds>@');
112}147}
113 148 
114export function ensureCacheParent(dir: string): void {149export function ensureCacheParent(dir: string): void {
@@ -6,5 +6,5 @@
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-export const VERSION = "0.1.3"9+export const VERSION = "0.1.4"
10export const VERSION_DISPLAY = `v${VERSION}`10export const VERSION_DISPLAY = `v${VERSION}`
@@ -0,0 +1,154 @@
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 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.
8+ 
9+// cpx LFS 模式 IT:CANNBAY2_LFS=1 + 本地 bare 仓(file:// 协议——git-lfs 的
10+// standalone 传输适配器只认 file:// URL,本地裸路径报 "no valid file:// URLs
11+// found")。端到端验证:
12+// ① .jsonl.gz 出仓为 LFS 指针(blob = version/oid/size 文本),实体落
13+// bare.git/lfs/objects/<oid 前缀分桶>,字节与源一致;meta.json 仍为
14+// 普通 blob(只有 .gz 声明走 LFS)
15+// ② .gitattributes 的 filter=lfs 行保留(LFS 模式与默认模式的"剔除"互为
16+// 镜像操作);缺行自动补
17+// ③ 幂等:指针级 unchanged(重复上传同内容零新 commit)
18+// ④ CANNBAY2_MAX_SESSION_MB 覆写:压缩字节超限熔断(LFS 模式的配套
19+// 容量开关,默认 100MB 是 git blob 的平台钳制)
20+// ⑤ subagents 同走 LFS
21+ 
22+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
23+import { execSync } from 'node:child_process';
24+import { createHash, randomBytes } from 'node:crypto';
25+import fs from 'node:fs';
26+import os from 'node:os';
27+import path from 'node:path';
28+import zlib from 'node:zlib';
29+import { hasGitLfs } from '../../src/cannbay/mirror';
30+ 
31+function git(cmd: string, cwd: string): string {
32+ return execSync(cmd, { cwd, stdio: 'pipe' }).toString();
33+}
34+ 
35+let tmp: string;
36+let bare: string;
37+let bareUrl: string;
38+let captures: string;
39+let cannbay: typeof import('../../src/cannbay');
40+ 
41+beforeAll(async () => {
42+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cannbay-lfs-it-'));
43+ bare = path.join(tmp, 'cannbay.git');
44+ bareUrl = `file://${bare}`;
45+ captures = path.join(tmp, 'captures');
46+ fs.mkdirSync(captures, { recursive: true });
47+ execSync(`git init --bare -b main "${bare}"`, { stdio: 'pipe' });
48+ const seed = path.join(tmp, 'seed');
49+ fs.mkdirSync(path.join(seed, 'agent-sift'), { recursive: true });
50+ fs.writeFileSync(path.join(seed, '.gitattributes'), '*.gz filter=lfs diff=lfs merge=lfs -text\n');
51+ fs.writeFileSync(path.join(seed, 'agent-sift', '.gitkeep'), '');
52+ git('git init -b main .', seed);
53+ git('git -c user.name=seed -c user.email=seed@x add -A', seed);
54+ git('git -c user.name=seed -c user.email=seed@x commit -m init', seed);
55+ git(`git push "${bare}" main`, seed);
56+ process.env.CANNBAY2_REMOTE_URL = bareUrl;
57+ process.env.CANNBAY2_PUSH_URL = bareUrl;
58+ process.env.CANNBAY2_CPX_CACHE_DIR = path.join(tmp, 'cache');
59+ process.env.CANNBAY2_LFS = '1';
60+ process.env.CANNBOT_PROXY_DIR = captures;
61+ cannbay = await import('../../src/cannbay');
62+});
63+ 
64+afterAll(() => {
65+ delete process.env.CANNBAY2_LFS;
66+ delete process.env.CANNBAY2_MAX_SESSION_MB;
67+});
68+ 
69+function writeCapture(sid: string, opts: { rawBytes?: number; subagents?: string[] } = {}): Buffer {
70+ const lines = [JSON.stringify({ type: 'wire', i: 0 }), JSON.stringify({ type: 'wire', i: 1 })];
71+ const text = lines.join('\n') + '\n';
72+ const gz = opts.rawBytes
73+ // 不可压缩随机负载(gzip 后 ≈ 原始字节):测大小限熔断用
74+ ? zlib.gzipSync(Buffer.concat([Buffer.from(text), randomBytes(opts.rawBytes)]))
75+ : zlib.gzipSync(text);
76+ fs.writeFileSync(path.join(captures, `cpx-${sid}.jsonl.gz`), gz);
77+ fs.writeFileSync(path.join(captures, `cpx-${sid}.meta.json`), JSON.stringify({
78+ x_cannbay: { schema: 'cc-session-meta', version: 1, data: { producer: 'cpx', framework: 'claude-code', protocol: 'anthropic', sid, governed: true } },
79+ }));
80+ if (opts.subagents) {
81+ const subDir = path.join(captures, sid, 'subagents');
82+ fs.mkdirSync(subDir, { recursive: true });
83+ for (const sub of opts.subagents) {
84+ fs.writeFileSync(path.join(subDir, `${sub}.jsonl.gz`), zlib.gzipSync(JSON.stringify({ type: 'wire', sub }) + '\n'));
85+ }
86+ }
87+ return gz;
88+}
89+ 
90+function blobBuffer(repoPath: string): Buffer {
91+ const oid = git(`git rev-parse main:${repoPath}`, bare).trim();
92+ return execSync(`git cat-file -p ${oid}`, { cwd: bare, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 256 * 1024 * 1024 }) as Buffer;
93+}
94+ 
95+function lfsObjectPath(oidHex: string): string {
96+ return path.join(bare, 'lfs', 'objects', oidHex.slice(0, 2), oidHex.slice(2, 4), oidHex);
97+}
98+ 
99+const DESC = '提交人: 李四\n内容描述: lfs it 上传\n日志路径: /x/cpx-s.jsonl.gz\n备注: 测试';
100+ 
101+describe.skipIf(!hasGitLfs())('LFS 模式直传(file:// bare + standalone 传输)', () => {
102+ it('①②⑤ 指针出仓 + 实体落 LFS 存储 + .gitattributes 保留 + subagents 同走 LFS', async () => {
103+ const gz = writeCapture('ses-lfs-e2e', { subagents: ['sub-1'] });
104+ const subGz = fs.readFileSync(path.join(captures, 'ses-lfs-e2e', 'subagents', 'sub-1.jsonl.gz'));
105+ const r = await cannbay.uploadCaptureSession({ sid: 'ses-lfs-e2e', captureFile: path.join(captures, 'cpx-ses-lfs-e2e.jsonl.gz'), description: DESC, category: 'agent-sift' });
106+ expect(r.folder).toBe('agent-sift/ses-lfs-e2e/');
107+ // LFS 模式不做"剔除"修复
108+ expect(r.gitattributesFixed).toBe(false);
109+ 
110+ // 主文件仓内 = LFS 指针文本(oid/size 与源 gzip 一致)
111+ const pointer = blobBuffer('agent-sift/ses-lfs-e2e/ses-lfs-e2e.jsonl.gz').toString('utf8');
112+ const oidHex = createHash('sha256').update(gz).digest('hex');
113+ expect(pointer.startsWith('version https://git-lfs.github.com/spec/v1')).toBe(true);
114+ expect(pointer).toContain(`oid sha256:${oidHex}`);
115+ expect(pointer).toContain(`size ${gz.length}`);
116+ // 实体已落 bare 的 LFS 存储,字节与源一致
117+ expect(fs.existsSync(lfsObjectPath(oidHex))).toBe(true);
118+ expect(fs.readFileSync(lfsObjectPath(oidHex)).equals(gz)).toBe(true);
119+ 
120+ // subagent 同走 LFS
121+ const subPointer = blobBuffer('agent-sift/ses-lfs-e2e/subagents/sub-1.jsonl.gz').toString('utf8');
122+ const subOid = createHash('sha256').update(subGz).digest('hex');
123+ expect(subPointer).toContain(`oid sha256:${subOid}`);
124+ expect(fs.existsSync(lfsObjectPath(subOid))).toBe(true);
125+ 
126+ // meta.json 仍是普通 blob(只有 .gz 声明走 LFS)
127+ const meta = blobBuffer('agent-sift/ses-lfs-e2e/ses-lfs-e2e.meta.json').toString('utf8');
128+ expect(meta).toContain('"producer":"cpx"');
129+ expect(meta).not.toContain('git-lfs');
130+ 
131+ // .gitattributes 的 filter=lfs 行保留
132+ expect(blobBuffer('.gitattributes').toString('utf8')).toContain('*.gz filter=lfs');
133+ });
134+ 
135+ it('③ 幂等:重复上传同内容 → 指针级 unchanged(零新 commit)', async () => {
136+ const cap = path.join(captures, 'cpx-ses-lfs-e2e.jsonl.gz');
137+ const commitsBefore = git('git rev-list --count main', bare).trim();
138+ const r = await cannbay.uploadCaptureSession({ sid: 'ses-lfs-e2e', captureFile: cap, description: DESC, category: 'agent-sift' });
139+ expect(r.unchanged).toBe(true);
140+ expect(git('git rev-list --count main', bare).trim()).toBe(commitsBefore);
141+ });
142+ 
143+ it('④ CANNBAY2_MAX_SESSION_MB 覆写:压缩字节超限熔断', async () => {
144+ writeCapture('ses-lfs-big', { rawBytes: 2 * 1024 * 1024 });
145+ process.env.CANNBAY2_MAX_SESSION_MB = '1';
146+ try {
147+ await expect(cannbay.uploadCaptureSession({ sid: 'ses-lfs-big', captureFile: path.join(captures, 'cpx-ses-lfs-big.jsonl.gz'), description: DESC, category: 'agent-sift' }))
148+ .rejects.toThrow('exceeds single-file size limit');
149+ expect(git('git ls-tree --name-only main agent-sift/ses-lfs-big', bare).trim()).toBe('');
150+ } finally {
151+ delete process.env.CANNBAY2_MAX_SESSION_MB;
152+ }
153+ });
154+});
@@ -21,7 +21,7 @@ import path from 'node:path';
21import { governText } from '../../src/cannbay/govern';21import { governText } from '../../src/cannbay/govern';
22import { extractSubmitter } from '../../src/cannbay';22import { extractSubmitter } from '../../src/cannbay';
23import { LFS_NEUTRALIZE } from '../../src/cannbay/mirror';23import { LFS_NEUTRALIZE } from '../../src/cannbay/mirror';
24-import { envPushUrl } from '../../src/cannbay/remote';24+import { envPushUrl, resolveCannbayRemote } from '../../src/cannbay/remote';
25import { governText as insightGovernText } from '../../../insight/src/lib/cannbay2/governance';25import { governText as insightGovernText } from '../../../insight/src/lib/cannbay2/governance';
26 26 
27const INSIGHT_MIRROR = path.resolve(__dirname, '../../../insight/src/lib/cannbay2/mirror.ts');27const INSIGHT_MIRROR = path.resolve(__dirname, '../../../insight/src/lib/cannbay2/mirror.ts');
@@ -77,12 +77,34 @@ describe('cannbay 受控双实现 parity(cpx ↔ insight)', () => {
77 expect(extractSubmitter('无标记').error).toContain('提交人不能为空');77 expect(extractSubmitter('无标记').error).toContain('提交人不能为空');
78 });78 });
79 79 
80- it('④ 内置 push 凭据同源(密文常量解码一致)', () => {80+ it('④ 内置 push 凭据同源(密文常量解码一致;push URL 不再内嵌凭据)', () => {
81 const insightSrc = fs.readFileSync(81 const insightSrc = fs.readFileSync(
82 path.resolve(__dirname, '../../../insight/src/lib/cannbay2/remote.ts'), 'utf8',82 path.resolve(__dirname, '../../../insight/src/lib/cannbay2/remote.ts'), 'utf8',
83 );83 );
84 const m = insightSrc.match(/CANNBAY2_PUSH_URL_ENCODED = '([A-Za-z0-9+/=]+)'/);84 const m = insightSrc.match(/CANNBAY2_PUSH_URL_ENCODED = '([A-Za-z0-9+/=]+)'/);
85 expect(m).toBeTruthy();85 expect(m).toBeTruthy();
86 expect(envPushUrl()).toBe(Buffer.from(m![1], 'base64').toString());86 expect(envPushUrl()).toBe(Buffer.from(m![1], 'base64').toString());
87+ // atomgit 拒"请求直带密码"的内嵌 URL(CH.00905401,2026-09-14 实测)→
88+ // push 凭据改经 credential helper 的 401 挑战-应答通道:两侧 mirror 都
89+ // 必须有 pushMirror(helper 脚本 + 继承链重置),remote 解析后 pushUrl
90+ // 无凭据、auth 从密文常量提取
91+ const cpxMirrorSrc = fs.readFileSync(path.resolve(__dirname, '../../src/cannbay/mirror.ts'), 'utf8');
92+ const insightMirrorSrc = fs.readFileSync(INSIGHT_MIRROR, 'utf8');
93+ for (const [name, src] of [['cpx', cpxMirrorSrc], ['insight', insightMirrorSrc]] as const) {
94+ expect(src, `${name} mirror 缺 pushMirror`).toContain('export function pushMirror');
95+ expect(src, `${name} pushMirror 未重置 credential.helper 继承链`).toContain('-c credential.helper= -c credential.helper=');
96+ expect(src, `${name} pushMirror 凭据须走一次性 helper 脚本`).toContain('mkdtempSync');
97+ }
98+ const saved = process.env.CANNBAY2_PUSH_URL;
99+ delete process.env.CANNBAY2_PUSH_URL;
100+ try {
101+ const r = resolveCannbayRemote();
102+ expect(r.pushUrl).not.toMatch(/@/);
103+ expect(r.pushUrl).toBe(r.pullUrl);
104+ expect(r.pushAuth?.username).toBeTruthy();
105+ expect(r.pushAuth?.password).toBeTruthy();
106+ } finally {
107+ if (saved !== undefined) process.env.CANNBAY2_PUSH_URL = saved;
108+ }
87 });109 });
88});110});
@@ -165,3 +165,63 @@ describe('uploadCaptureSession 直传(真 bare 仓)', () => {
165 expect(treeAt('origin/main', 'agent-sift/ses-base-a').length).toBeGreaterThan(0);165 expect(treeAt('origin/main', 'agent-sift/ses-base-a').length).toBeGreaterThan(0);
166 });166 });
167});167});
168+ 
169+describe('pushMirror(401 挑战-应答通道)', () => {
170+ it('parseUrlCreds:内嵌凭据拆分 / 编码解码 / 无凭据与本地路径原样', async () => {
171+ const { parseUrlCreds } = await import('../../src/cannbay/remote');
172+ const a = parseUrlCreds('https://duanbingzhe:tok@atomgit.com/duanbingzhe/cannbay3.git');
173+ expect(a.credless).toBe('https://atomgit.com/duanbingzhe/cannbay3.git');
174+ expect(a.auth).toEqual({ username: 'duanbingzhe', password: 'tok' });
175+ const b = parseUrlCreds('https://oauth2:tok%40x%3A@host/repo.git');
176+ expect(b.auth).toEqual({ username: 'oauth2', password: 'tok@x:' });
177+ expect(b.credless).toBe('https://host/repo.git');
178+ const c = parseUrlCreds('https://atomgit.com/duanbingzhe/cannbay3.git');
179+ expect(c.auth).toBeNull();
180+ expect(c.credless).toBe('https://atomgit.com/duanbingzhe/cannbay3.git');
181+ const d = parseUrlCreds('/tmp/cannbay-upload-it/cannbay.git');
182+ expect(d.auth).toBeNull();
183+ expect(d.credless).toBe('/tmp/cannbay-upload-it/cannbay.git');
184+ });
185+ 
186+ it('pushAuth 存在:一次性 helper 构造/清理 + push 成功(本地 bare 传输不咨询凭据,锁命令构造与生命周期)', async () => {
187+ const { pushMirror } = await import('../../src/cannbay/mirror');
188+ const before = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('cpx-cannbay-auth-'));
189+ const pmBare = path.join(tmp, 'pm-bare.git');
190+ execSync(`git init --bare -b main "${pmBare}"`, { stdio: 'pipe' });
191+ const work = path.join(tmp, 'pushmirror');
192+ fs.mkdirSync(work, { recursive: true });
193+ git('git init -b main .', work);
194+ fs.writeFileSync(path.join(work, 'pm.txt'), 'x');
195+ git('git -c user.name=t -c user.email=t@x add -A', work);
196+ git('git -c user.name=t -c user.email=t@x commit -m pm', work);
197+ const remote = {
198+ pullUrl: pmBare, pushUrl: pmBare,
199+ pushAuth: { username: 'it-user', password: 'it-pass' },
200+ branch: 'main', rootDir: 'sessions', cacheDir: path.join(tmp, 'pm-cache'), paradigmRoots: true,
201+ };
202+ pushMirror(work, remote);
203+ expect(git('git rev-parse main', pmBare).trim()).toBe(git('git rev-parse HEAD', work).trim());
204+ const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('cpx-cannbay-auth-'));
205+ expect(after).toEqual(before); // helper 临时目录用后即删
206+ 
207+ const noAuth = { ...remote, pushAuth: null };
208+ fs.writeFileSync(path.join(work, 'pm2.txt'), 'y');
209+ git('git -c user.name=t -c user.email=t@x add -A', work);
210+ git('git -c user.name=t -c user.email=t@x commit -m pm2', work);
211+ pushMirror(work, noAuth);
212+ expect(git('git rev-parse main', pmBare).trim()).toBe(git('git rev-parse HEAD', work).trim());
213+ });
214+ 
215+ it('pushAuth 含换行:helper 协议非法,立即拒绝且不落任何临时目录', async () => {
216+ const { pushMirror } = await import('../../src/cannbay/mirror');
217+ const before = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('cpx-cannbay-auth-'));
218+ const remote = {
219+ pullUrl: bare, pushUrl: bare,
220+ pushAuth: { username: 'u', password: 'bad\npassword' },
221+ branch: 'main', rootDir: 'sessions', cacheDir: path.join(tmp, 'pm-cache'), paradigmRoots: true,
222+ };
223+ expect(() => pushMirror(path.join(tmp, 'pushmirror'), remote)).toThrow('换行');
224+ const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('cpx-cannbay-auth-'));
225+ expect(after).toEqual(before);
226+ });
227+});
@@ -0,0 +1,98 @@
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+// context 品牌变体发布链路 IT:pack-context.mjs 打包 → 解包 → 品牌切换断言 +
10+// 格式契约保留断言 + 品牌泄漏哨兵(源码品牌字面量漂移会让打包期 minHits 先炸,
11+// 这里兜底扫产物,双保险)。cpx 无构建步骤(源码直发),静态断言即运行时断言。
12+ 
13+import { describe, it, expect, beforeAll, afterAll } from 'vitest'
14+import { spawnSync } from 'node:child_process'
15+import fs from 'node:fs'
16+import os from 'node:os'
17+import path from 'node:path'
18+import { fileURLToPath } from 'node:url'
19+ 
20+const PKG_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
21+ 
22+const TEXT_EXTS = new Set([
23+ '.ts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.py',
24+ '.css', '.scss', '.html', '.txt', '.sh', '.yml', '.yaml', '.sql',
25+ '.prisma', '.xml', '.svg', '.example',
26+])
27+ 
28+let extracted = ''
29+let tmpRoot = ''
30+ 
31+function read(rel: string): string {
32+ return fs.readFileSync(path.join(extracted, rel), 'utf8')
33+}
34+ 
35+function scanForbidden(root: string, forbidden: string[]): string[] {
36+ const leaks: string[] = []
37+ const walk = (dir: string) => {
38+ for (const name of fs.readdirSync(dir)) {
39+ if (name === 'node_modules') continue
40+ const full = path.join(dir, name)
41+ if (fs.statSync(full).isDirectory()) { walk(full); continue }
42+ if (!TEXT_EXTS.has(path.extname(name))) continue
43+ const text = fs.readFileSync(full, 'utf8')
44+ for (const bad of forbidden) {
45+ if (text.includes(bad)) leaks.push(`${path.relative(root, full)}: ${bad}`)
46+ }
47+ }
48+ }
49+ walk(root)
50+ return leaks
51+}
52+ 
53+describe('context 品牌变体打包(pack-context.mjs)', () => {
54+ beforeAll(() => {
55+ const r = spawnSync(process.execPath, ['scripts/pack-context.mjs'], {
56+ cwd: PKG_DIR, encoding: 'utf8', timeout: 120_000, maxBuffer: 32 * 1024 * 1024,
57+ })
58+ expect(r.status, `pack-context.mjs 失败:\n${r.stderr}`).toBe(0)
59+ 
60+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ctx-cpx-'))
61+ const tgz = fs.readdirSync(path.join(PKG_DIR, '.context-pack')).find((f) => f.endsWith('.tgz'))
62+ expect(tgz, '产物 tgz 缺失').toBeTruthy()
63+ expect(tgz!.startsWith('context-cpx-')).toBe(true)
64+ const t = spawnSync('tar', ['-xzf', path.join(PKG_DIR, '.context-pack', tgz!), '-C', tmpRoot], {
65+ encoding: 'utf8', timeout: 60_000,
66+ })
67+ expect(t.status, t.stderr).toBe(0)
68+ extracted = path.join(tmpRoot, 'package')
69+ }, 180_000)
70+ 
71+ afterAll(() => {
72+ try { fs.rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* tmp 目录留置无害 */ }
73+ })
74+ 
75+ it('package.json:包名 context-cpx / bin 保持 cpx / 描述切换', () => {
76+ const pkg = JSON.parse(read('package.json'))
77+ expect(pkg.name).toBe('context-cpx')
78+ expect(pkg.bin).toEqual({ cpx: './bin/cpx.mjs' })
79+ expect(pkg.description).toContain('Context-Insight')
80+ })
81+ 
82+ it('捕获与缓存目录默认值翻转(与 context-insight 数据目录配对)', () => {
83+ expect(read('src/writer.ts')).toContain("'.context-insight'")
84+ expect(read('src/cli/cpx-upload.ts')).toContain("'.context-insight'")
85+ expect(read('src/cannbay/remote.ts')).toContain("'.context-insight'")
86+ })
87+ 
88+ it('品牌泄漏哨兵:显示名 / Bay 名(大小写两种形态)/ 数据目录路径零残留', () => {
89+ const leaks = scanForbidden(extracted, ['CANNBot-Insight', 'CANNbay', 'CANNBay', '[cbi]', '.cannbot-insight'])
90+ expect(leaks, `品牌泄漏:\n${leaks.join('\n')}`).toEqual([])
91+ })
92+ 
93+ it('格式契约保留:x_cannbay 命名空间 / CANNBOT_PROXY_DIR 环境变量', () => {
94+ expect(read('src/claude-emitter.ts')).toContain('x_cannbay')
95+ expect(read('src/writer.ts')).toContain('CANNBOT_PROXY_DIR')
96+ expect(read('README.md')).toContain('CANNBAY2_LFS')
97+ })
98+})
Mpackages/insight/.npmignore+3-0文件内容审核中,请稍后刷新重试
@@ -154,6 +154,7 @@ proxy(cpx 捕获)是 insight 的扩展来源,不是核心管线的一部
154## Constraints & Gotchas154## Constraints & Gotchas
155 155 
156- **cannbay2 镜像的 git 命令必须走 `runGit`**(mirror.ts):其 per-command 配置中和 lfs filter(`filter.lfs.process= / smudge=cat / clean=cat / required=false`)+ `push --no-verify`。数据仓 `.gitattributes` 历史上带 `*.gz filter=lfs`,装 git-lfs 的客户机会在 checkout(孤儿指针 smudge 403)与 add(clean 指针化)两条路上炸;绕过 runGit 直拼 git 命令会重新暴露(IT:`tests/cannbay2-lfs-immunity.test.ts`)156- **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`)
157+- **双名发布(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 机器提交身份)**不得品牌化**——两品牌数据互通靠它们
157- **ESM strict** (`"type": "module"` in package.json) — no `require()`, use `import`158- **ESM strict** (`"type": "module"` in package.json) — no `require()`, use `import`
158- **Ink v7 has no lastFrame/frames/output** on native `render()` — use `ink-testing-library` for tests only159- **Ink v7 has no lastFrame/frames/output** on native `render()` — use `ink-testing-library` for tests only
159- **No third-party Ink components** — ink-table/ink-select/ink-spinner incompatible with Ink v7; self-implement all components160- **No third-party Ink components** — ink-table/ink-select/ink-spinner incompatible with Ink v7; self-implement all components
@@ -2,6 +2,13 @@
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+## [v0.2.29] - 2026-09-16
6+ 
7+### Added
8+- 【双名发布:context 品牌变体(`context-insight`,bin `context-insight` / `ctxi`)】同一源码仓、打包期品牌变换——`scripts/pack-context.mjs`:`npm pack --dry-run` 取本包真实清单 → staging 复制(bin 启动器改名 `context-insight.mjs`)→ 精确字面量替换(**每条规则带 minHits 防漂移哨兵**,源码品牌字面量漂移时打包即报错指路)→ staging 内 `npm pack`(prepack 的 build-cli 在 staging 跑,CLI bundle 品牌随源码变换)。翻转面:显示名 `Context-Insight` / CLI 别名 `ctxi` / 数据目录 `~/.context-insight/` / Bay 显示名 `contextBay`(**UI 字符串一律经 `BAY_NAME` 常量(`@/lib/branding`)取值**——此前大量硬编码大写 B 的 `CANNBay`(上传按钮/弹窗标题/CLI 描述与结果/TUI 面板/导入向导),规则表只覆盖小写 b 的 `"CANNbay"`,context 成品 Bay 名一半品牌一半残留;源码级修复后两品牌同时受益,v1 路由错误消息同步改 BAY_NAME)/ 导入对话框快捷路径;保留面(**两品牌数据互通的契约,不得品牌化**):数据格式标识 `BRAND_SOURCE_TYPE="cannbot-insight"`、`x_cannbay` 命名空间、`CANNBAY2_*`/`CANNBOT_*` env 名、smart-agent 服务标识 `cannbot-smart-agent`、cannbay2 机器提交身份、v1 路由仓 URL(`guanxinghua/CANNBay.git` 是真实仓库名,替换会断链——哨兵对其豁免)。发布:`node scripts/pack-context.mjs` → `npm publish .context-pack/context-insight-<ver>.tgz`(与主包同版本号双发)。IT:`tests/context-pack.test.ts`(打包→解包→package.json/bin 断言 + CLI bundle 实跑 `--version`=`Context-Insight vX` + 品牌泄漏哨兵全树扫描——**覆盖 `CANNbay`/`CANNBay` 两种大小写形态**(v1 仓 URL 豁免剥除后再扫)+ 契约保留断言);`.context-pack/` 进 .npmignore 与根 .gitignore(防上次打包产物污染下次 dry-run 清单——实际炸过:tgz 被收进清单、rm -rf 后复制 ENOENT)
9+- 【cannbay2 LFS 模式(`config.lfs` > env `CANNBAY2_LFS`,默认关)——上传/导入双域支持,容量脱离 git 仓配额】数据仓启用 LFS 后上传走 LFS 指针出仓(`.jsonl.gz` blob=指针,实体进 LFS 存储),数据量不再计入 atomgit git_repo 1GiB 硬配额;**导入闭环同步支持**——`materializeSession` 在 LFS 模式改用 `git cat-file --filters`(smudge 按需拉实体,`.git/lfs/objects` 本地缓存),且自动从 origin/main 物化工作树 `.gitattributes`(--filters 的属性解析依赖);列表零影响(meta.json 是普通 blob)。**cat-file 走 spawnSync argv 形式**——repoPath 来自 `ls-tree -z` 原始输出(除 NUL 外任意字节都可能),内插进 shell 双引号字符串即命令注入面(恶意文件名 `sub"; cmd; ".jsonl.gz` 可在导入方机器执行任意命令),argv 传参彻底绕开 shell(oid 是十六进制无此风险)。**工作树 `.gitattributes` 判定对齐 `ensureLfsGitattributes`(缺 `*.gz filter=lfs` 声明即从 origin/main 刷新,非仅看文件存在)**——同一镜像默认模式上传会把剔除过 lfs 行的副本写回工作树(LFS 免疫操作),此后远端切回 LFS 模式时过期副本让 `--filters` 不做 smudge,指针文本被当作 gzip 内容落盘、导入在解压环节报费解错误。镜像内手工写仓级 `filter.lfs.*` + pre-push 钩子(不碰用户全局配置),LFS 模式 add/push 跳过 filter 中和、push 不带 `--no-verify`(钩子=LFS 对象上传通道);`.gitattributes` 反向操作——确保 `*.gz filter=lfs` 声明存在(默认模式仍是剔除,LFS 免疫行为不变);git-lfs 不在机 fail-fast。请求体 config 增可选 `lfs` 字段(`CannbayRemoteInput`;Web UI 开关后续接线)。配套 env `CANNBAY2_MAX_SESSION_MB` 覆写单会话大小限(默认 100MB;测试注入 `.value` 机制不变)。与 cpx `src/cannbay/` 受控双实现(parity 锁定),IT:`tests/cannbay2-lfs.test.ts`(file:// bare + standalone 传输,无 git-lfs 机器自动跳过;含注入探针 ⑤ 与过期 .gitattributes 回归锁 ⑥)
10+- 【LFS 免疫 IT 修复:seed 推送与机器 git-lfs 状态解耦】装了 git-lfs 的机器上新克隆自动带 lfs pre-push 钩子,`cannbay2-lfs-immunity.test.ts` 的 seed 推送(故意放置孤儿指针)被真实钩子拦截 → collect 期失败;seed push 补 `--no-verify`(免疫验证另有三层防线,不依赖 seed 路径)
11+ 
5## [v0.2.28] - 2026-09-1412## [v0.2.28] - 2026-09-14
6 13 
7### Added14### Added
@@ -99,6 +99,7 @@ proxy(cpx 捕获)是 insight 的扩展来源,不是核心管线的一部
99 99 
100## Constraints & Gotchas100## Constraints & Gotchas
101 101 
102+- **双名发布(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 机器提交身份)**不得品牌化**——两品牌数据互通靠它们
102- **ESM strict** (`"type": "module"` in package.json) — no `require()`, use `import`103- **ESM strict** (`"type": "module"` in package.json) — no `require()`, use `import`
103- **Ink v7 has no lastFrame/frames/output** on native `render()` — use `ink-testing-library` for tests only104- **Ink v7 has no lastFrame/frames/output** on native `render()` — use `ink-testing-library` for tests only
104- **No third-party Ink components** — ink-table/ink-select/ink-spinner incompatible with Ink v7; self-implement all components105- **No third-party Ink components** — ink-table/ink-select/ink-spinner incompatible with Ink v7; self-implement all components
@@ -37,14 +37,16 @@ cannbot-insight # 首次:自动 build + migrate(约 30s-2min)
37- 需 Node.js >= 20;国内网络可加 `--registry=https://registry.npmmirror.com`37- 需 Node.js >= 20;国内网络可加 `--registry=https://registry.npmmirror.com`
38- 离线环境可用 tgz:`npm install -g ./cannbot-insight-<版本>.tgz`38- 离线环境可用 tgz:`npm install -g ./cannbot-insight-<版本>.tgz`
39- 可写状态集中在 `~/.cannbot-insight/`;默认端口 21025(占用自动顺延)39- 可写状态集中在 `~/.cannbot-insight/`;默认端口 21025(占用自动顺延)
40-- CLI 子命令直接透传:`cannbot-insight upload --file … --list` / `sessions` / `--help`40+- CLI 子命令直接透传:`cannbot-insight upload --file … --list` / `sessions`;短别名 `cbi`
41- Python 3 可选(存在则启动 smart-agent 分析后端,缺失静默降级)41- Python 3 可选(存在则启动 smart-agent 分析后端,缺失静默降级)
42+- 别名品牌:`npm install -g context-insight` —— 同一工具以 Context-Insight 品牌名发布(命令 `context-insight` / `ctxi`,数据目录 `~/.context-insight/`);会话数据与 cannbot-insight 完全互通(共享同一格式标识)
42 43 
43| 标志 | 作用 |44| 标志 | 作用 |
44|------|------|45|------|------|
45| `-a`, `--advanced` | 显示高级 tab(wireRounds/replay) |46| `-a`, `--advanced` | 显示高级 tab(wireRounds/replay) |
46| `-k`, `--kill` | 停掉本仓所有 insight/smart-agent 实例后从 21025 启动(被他方占用时顺延下一个空闲端口) |47| `-k`, `--kill` | 停掉本仓所有 insight/smart-agent 实例后从 21025 启动(被他方占用时顺延下一个空闲端口) |
47| `-f`, `--fresh` | 清空构建缓存重建 |48| `-f`, `--fresh` | 清空构建缓存重建 |
49+| `-h`, `--help` | 打印完整 CLI 参考 —— 全部子命令与旗标(含 `-v, --version` 版本输出) |
48 50 
49### 方式一:源码运行 Web UI51### 方式一:源码运行 Web UI
50 52 
@@ -62,7 +64,7 @@ cannbot-insight # 首次:自动 build + migrate(约 30s-2min)
62- opencode: `~/.local/share/opencode/opencode.db`64- opencode: `~/.local/share/opencode/opencode.db`
63- Claude Code: `~/.claude/projects/`(目录自动扫描)65- Claude Code: `~/.claude/projects/`(目录自动扫描)
64 66 
65-Web UI 还支持:导出 session 为独立 SQLite 或 Markdown;上传 CANNBay 数据集仓(范式目录 + 算子生成结果标注 + 可选 baseline,上传前强制密钥治理);从 CANNBay 列表按需导入。数据湖远端、认证与分支可在设置页配置(凭据仅存浏览器 localStorage)。LLM 接入面板内置常见提供商 Base URL 速查。67+Web UI 还支持:导出 session 为独立 SQLite 或 Markdown;上传 CANNBay 数据集仓(范式目录 + 算子生成结果标注 + 可选 baseline,上传前强制密钥治理);从 CANNBay 列表按需导入。数据湖远端、认证与分支可在设置页配置(凭据仅存浏览器 localStorage)。LLM 接入面板内置常见提供商 Base URL 速查。数据仓在平台侧启用 LFS 后可设 env `CANNBAY2_LFS=1`(或请求体 config `lfs`)切换 LFS 指针出仓——容量脱离 git 仓配额,导入链路透明支持(meta 仍是普通 blob,会话实体按需从 LFS 存储拉取)。
66 68 
67### 方式二:CLI 上传 + Web 分析69### 方式二:CLI 上传 + Web 分析
68 70 
@@ -37,14 +37,16 @@ cannbot-insight # first run: auto-build + migrate (~30s-2min), then s
37- Requires Node.js >= 20; mainland-China networks can add `--registry=https://registry.npmmirror.com`37- Requires Node.js >= 20; mainland-China networks can add `--registry=https://registry.npmmirror.com`
38- Offline fallback: `npm install -g ./cannbot-insight-<version>.tgz`38- Offline fallback: `npm install -g ./cannbot-insight-<version>.tgz`
39- Writable state lives in `~/.cannbot-insight/`; default port 21025 (auto-increments if occupied)39- Writable state lives in `~/.cannbot-insight/`; default port 21025 (auto-increments if occupied)
40-- CLI subcommands pass through: `cannbot-insight upload --file … --list` / `sessions` / `--help`40+- CLI subcommands pass through: `cannbot-insight upload --file … --list` / `sessions`; short alias `cbi`
41- Python 3 optional (starts the smart-agent analysis backend when present)41- Python 3 optional (starts the smart-agent analysis backend when present)
42+- Alternate brand: `npm install -g context-insight` — the same tool published under the Context-Insight name (commands `context-insight` / `ctxi`, data dir `~/.context-insight/`); session data is fully interchangeable with cannbot-insight (shared format identity)
42 43 
43| Flag | Effect |44| Flag | Effect |
44|------|--------|45|------|--------|
45| `-a`, `--advanced` | Show advanced tabs (wireRounds/replay) |46| `-a`, `--advanced` | Show advanced tabs (wireRounds/replay) |
46| `-k`, `--kill` | Stop all insight/smart-agent instances of this repo, then start on 21025 (falls back to the next free port if occupied by others) |47| `-k`, `--kill` | Stop all insight/smart-agent instances of this repo, then start on 21025 (falls back to the next free port if occupied by others) |
47| `-f`, `--fresh` | Clear the build cache and rebuild |48| `-f`, `--fresh` | Clear the build cache and rebuild |
49+| `-h`, `--help` | Print the full CLI reference — all subcommands and flags (incl. `-v, --version`) |
48 50 
49### Option 1: Web UI from source51### Option 1: Web UI from source
50 52 
@@ -62,7 +64,7 @@ Log file locations:
62- opencode: `~/.local/share/opencode/opencode.db`64- opencode: `~/.local/share/opencode/opencode.db`
63- Claude Code: `~/.claude/projects/` (directory auto-scan)65- Claude Code: `~/.claude/projects/` (directory auto-scan)
64 66 
65-The Web UI also supports: exporting sessions to standalone SQLite or Markdown; uploading to the CANNBay dataset repo (paradigm directory + operator-generation outcome annotation + optional baseline, with mandatory secret governance before push); on-demand import from the CANNBay list. The data-lake remote, auth, and branch are configurable in Settings (credentials stay in browser localStorage). The LLM provider panel embeds a known-provider Base URL quick reference.67+The Web UI also supports: exporting sessions to standalone SQLite or Markdown; uploading to the CANNBay dataset repo (paradigm directory + operator-generation outcome annotation + optional baseline, with mandatory secret governance before push); on-demand import from the CANNBay list. The data-lake remote, auth, and branch are configurable in Settings (credentials stay in browser localStorage). The LLM provider panel embeds a known-provider Base URL quick reference. When the data repo has LFS enabled on the platform, set env `CANNBAY2_LFS=1` (or request config `lfs`) to store `.jsonl.gz` sessions as LFS pointers — capacity escapes the git repo quota, and the import path handles it transparently (meta stays a plain blob; session payloads are fetched from LFS storage on demand).
66 68 
67### Option 2: CLI Upload + Web Analysis69### Option 2: CLI Upload + Web Analysis
68 70 
@@ -30,7 +30,7 @@ const BASE_PORT = Number.parseInt(process.env.CANNBOT_INSIGHT_PORT || '21025', 1
30const CLI_COMMANDS = new Set([30const CLI_COMMANDS = new Set([
31 'tui', 'sessions', 'session', 'stats', 'compare', 'search', 'turn',31 'tui', 'sessions', 'session', 'stats', 'compare', 'search', 'turn',
32 'import', 'delete', 'config', 'analyze', 'export', 'export-md', 'upload',32 'import', 'delete', 'config', 'analyze', 'export', 'export-md', 'upload',
33- 'help', '--help', '-h', '--version', '-V',33+ 'help', '--help', '-h', '--version', '-V', '-v',
34])34])
35 35 
36const args = process.argv.slice(2)36const args = process.argv.slice(2)
@@ -30,6 +30,8 @@ const eslintConfig = defineConfig([
30 "out/**",30 "out/**",
31 "build/**",31 "build/**",
32 "next-env.d.ts",32 "next-env.d.ts",
33+ // context 品牌变体打包工作区(scripts/pack-context.mjs 的 staging 复制树)
34+ ".context-pack/**",
33 ]),35 ]),
34]);36]);
35 37 
@@ -1,6 +1,6 @@
1{1{
2 "name": "cannbot-insight",2 "name": "cannbot-insight",
3- "version": "0.2.28",3+ "version": "0.2.29",
4 "description": "Session-level observability tool for LLM coding agents (opencode/Claude Code)",4 "description": "Session-level observability tool for LLM coding agents (opencode/Claude Code)",
5 "license": "SEE LICENSE IN LICENSE",5 "license": "SEE LICENSE IN LICENSE",
6 "type": "module",6 "type": "module",
@@ -0,0 +1,167 @@
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+// context 品牌变体打包(npm pack):
10+// node scripts/pack-context.mjs → .context-pack/context-insight-<version>.tgz
11+// 发布:npm publish .context-pack/context-insight-<version>.tgz
12+//
13+// 机制:单源码仓 + 打包期变换 —— `npm pack --dry-run` 取本包真实清单,复制到
14+// staging 后做精确字面量替换(规则表见下),staging 内 `npm pack`(prepack 的
15+// build-cli.mjs 在 staging 跑,产出品牌变换后的 dist/cli.mjs)。源码仓零改动。
16+//
17+// 品牌边界(与 cannbot-insight 同内容、仅品牌面不同):
18+// 翻转 —— 显示名 / CLI 别名 / bin 文件名 / 数据目录(~/.context-insight)/
19+// Bay 显示名(UI 字符串一律经 BAY_NAME 常量(@/lib/branding)取值,
20+// 品牌随 BRAND_SLUG 翻转;branding.ts 里的 "CANNbay" 字面量由下方
21+// 通用规则恒等化,仅为让哨兵测试零误报)
22+// 保留 —— 数据格式标识(BRAND_SOURCE_TYPE="cannbot-insight",两品牌数据互通)/
23+// x_cannbay 命名空间 / CANNBAY2_* 与 CANNBOT_* 环境变量名 /
24+// smart-agent 服务标识(cannbot-smart-agent,启动器 isSmartAgent 契约)/
25+// git 机器提交身份(cannbay2 mirror,数据仓内身份稳定)/
26+// v1 路由仓 URL(guanxinghua/CANNBay.git 是真实仓库名,非品牌显示,
27+// 不能替换——哨兵对其豁免)
28+//
29+// 每条规则带 minHits(防漂移哨兵):源码重构后若必命中的规则命中 0 次,打包报错,
30+// 提示同步更新规则表。tests/context-pack.test.ts 对产物做品牌泄漏扫描。
31+ 
32+import { spawnSync } from 'node:child_process'
33+import fs from 'node:fs'
34+import path from 'node:path'
35+import { fileURLToPath } from 'node:url'
36+ 
37+const PKG_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
38+const STAGING = path.join(PKG_DIR, '.context-pack', 'pkg')
39+const OUT_DIR = path.join(PKG_DIR, '.context-pack')
40+ 
41+// 文件重命名(bin 启动器随包名改名;package.json bin 值在结构化改写中同步)
42+const RENAMES = new Map([['bin/cannbot-insight.mjs', 'bin/context-insight.mjs']])
43+ 
44+// 不进 context 变体包:打包脚本自身(规则串自引用,会误触品牌泄漏哨兵)
45+const EXCLUDE = new Set(['scripts/pack-context.mjs'])
46+ 
47+// 通用规则(全部文本文件;精确字符串替换,非正则)
48+const GLOBAL_RULES = [
49+ { find: 'CANNBot-Insight', replace: 'Context-Insight', minHits: 1 },
50+ { find: '"CANNbay"', replace: '"contextBay"', minHits: 1 },
H
Hhangdu26 天前

规则表与泄漏哨兵都只覆盖小写 b 的 "CANNbay",但源码里大量用户可见字符串硬编码的是大写 B 的 CANNBay(UploadToCannbayDialog.tsx 的「上传到 CANNBay」、SessionList.tsx / session 页的 title="Upload to CANNBay"、LocalFileImport.tsx 的 "Fetch from CANNBay"、cli/commands/upload.ts、tui/components/UploadPanel.tsx 等,均在发包清单内)。GLOBAL_RULES 对 .ts/.tsx 不含 CANNBay → context-insight 成品里 Bay 名一半是 contextBay(branding.ts 三元生效)、一半仍是 CANN 品牌;tests/context-pack.test.ts 的 forbidden 列表也没有 'CANNBay',哨兵测不出来。注意不能简单加全局 CANNBay→contextBay 规则:v1 路由 import-from-cannbay/route.ts / upload-session/route.ts 硬编码了 https://gitcode.com/guanxinghua/CANNBay.git 仓 URL,替换会断链。建议 UI 字符串改用 BAY_NAME 常量(源码级修复,两品牌同时受益),哨兵补扫 'CANNBay'(排除那两个 v1 路由文件或其中的 URL)。

likedislike
51+ { find: "'.cannbot-insight'", replace: "'.context-insight'", minHits: 2 },
52+ { find: '~/.cannbot-insight', replace: '~/.context-insight', minHits: 2 },
53+ { find: '[cbi]', replace: '[ctxi]', minHits: 2 },
54+ { find: '"cbi"', replace: '"ctxi"', minHits: 1 },
55+ { find: 'cannbot-insight.mjs', replace: 'context-insight.mjs', minHits: 2 },
56+ { find: '[cannbot-insight postinstall]', replace: '[context-insight postinstall]', minHits: 1 },
57+]
58+ 
59+// 文档规则(*.md:README / docs / prompts —— 面向 context 包用户,包名与按钮名全部翻转;
60+// 长串规则已在通用层先跑,这里翻剩余的无点小写包名)
61+const MD_RULES = [
62+ { find: 'cannbot-proxy', replace: 'context-cpx', minHits: 0 },
63+ { find: 'cannbot-insight', replace: 'context-insight', minHits: 1 },
64+ { find: 'CANNBay', replace: 'contextBay', minHits: 0 },
65+ { find: 'CANNbay', replace: 'contextBay', minHits: 0 },
66+]
67+ 
68+const TEXT_EXTS = new Set([
69+ '.ts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.py',
70+ '.css', '.scss', '.html', '.txt', '.sh', '.yml', '.yaml', '.sql',
71+ '.prisma', '.xml', '.svg', '.example',
72+])
73+ 
74+function rewritePkgJson(pkg) {
75+ pkg.name = 'context-insight'
76+ pkg.description = 'Session-level observability tool for LLM coding agents (opencode/Claude Code)'
77+ pkg.bin = { 'context-insight': './bin/context-insight.mjs', ctxi: './bin/context-insight.mjs' }
78+ pkg.keywords = [
79+ 'context-insight', 'context-cpx', 'llm', 'agent', 'observability',
80+ 'session', 'opencode', 'claude-code', 'tokens', 'timeline',
81+ ]
82+ return pkg
83+}
84+ 
85+function packFileList() {
86+ const r = spawnSync('npm', ['pack', '--dry-run', '--json'], {
87+ cwd: PKG_DIR, encoding: 'utf8', timeout: 120_000, maxBuffer: 32 * 1024 * 1024,
88+ })
89+ if (r.status !== 0) throw new Error(`npm pack --dry-run 失败:\n${r.stderr}`)
90+ const parsed = JSON.parse(r.stdout)
91+ return parsed[0].files.map((f) => f.path)
92+}
93+ 
94+function copyTree(files) {
95+ fs.rmSync(path.join(PKG_DIR, '.context-pack'), { recursive: true, force: true })
96+ fs.mkdirSync(STAGING, { recursive: true })
97+ for (const rel of files) {
98+ if (EXCLUDE.has(rel)) continue
99+ // 防御:上次运行的 .context-pack 产物若混入清单(如 .npmignore 缺失时),
100+ // rm -rf 后源已不存在,复制必 ENOENT —— 一律跳过
101+ if (rel === '.context-pack' || rel.startsWith('.context-pack/')) continue
102+ const src = path.join(PKG_DIR, rel)
103+ const dst = path.join(STAGING, RENAMES.get(rel) ?? rel)
104+ fs.mkdirSync(path.dirname(dst), { recursive: true })
105+ fs.copyFileSync(src, dst)
106+ }
107+}
108+ 
109+function transformTree() {
110+ const hits = new Map()
111+ for (const rule of [...GLOBAL_RULES, ...MD_RULES]) hits.set(rule.find, 0)
112+ const walk = (dir) => {
113+ for (const name of fs.readdirSync(dir)) {
114+ const full = path.join(dir, name)
115+ const stat = fs.statSync(full)
116+ if (stat.isDirectory()) { walk(full); continue }
117+ const rel = path.relative(STAGING, full)
118+ if (rel === 'package.json') continue
119+ if (!TEXT_EXTS.has(path.extname(name))) continue
120+ let text = fs.readFileSync(full, 'utf8')
121+ const rules = path.extname(name) === '.md' ? [...GLOBAL_RULES, ...MD_RULES] : GLOBAL_RULES
122+ for (const rule of rules) {
123+ const n = text.split(rule.find).length - 1
124+ if (n > 0) { text = text.split(rule.find).join(rule.replace); hits.set(rule.find, hits.get(rule.find) + n) }
125+ }
126+ if (text !== fs.readFileSync(full, 'utf8')) fs.writeFileSync(full, text)
127+ }
128+ }
129+ walk(STAGING)
130+ 
131+ const pkgPath = path.join(STAGING, 'package.json')
132+ const pkg = rewritePkgJson(JSON.parse(fs.readFileSync(pkgPath, 'utf8')))
133+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
134+ 
135+ for (const rule of [...GLOBAL_RULES, ...MD_RULES]) {
136+ if ((hits.get(rule.find) ?? 0) < rule.minHits) {
137+ throw new Error(
138+ `规则 "${rule.find}" 命中 ${hits.get(rule.find) ?? 0} 次 < 最低 ${rule.minHits} —— ` +
139+ '源码品牌字面量可能已漂移,请核对 scripts/pack-context.mjs 规则表',
140+ )
141+ }
142+ }
143+ return hits
144+}
145+ 
146+function main() {
147+ const files = packFileList()
148+ copyTree(files)
149+ const hits = transformTree()
150+ 
151+ // prepack(build-cli.mjs)需要 esbuild 等 devDeps —— symlink 复用包根 node_modules;
152+ // npm pack 内建忽略 node_modules,不会进 tgz。
153+ fs.symlinkSync(path.join(PKG_DIR, 'node_modules'), path.join(STAGING, 'node_modules'), 'dir')
154+ 
155+ const r = spawnSync('npm', ['pack', '--json', '--pack-destination', OUT_DIR], {
156+ cwd: STAGING, encoding: 'utf8', timeout: 180_000, maxBuffer: 32 * 1024 * 1024,
157+ })
158+ if (r.status !== 0) throw new Error(`staging npm pack 失败:\n${r.stderr}`)
159+ const tgzName = JSON.parse(r.stdout)[0].filename
160+ 
161+ console.log(`[pack-context] 规则命中统计:`)
162+ for (const [find, n] of hits) if (n > 0) console.log(` ${n}× ${find}`)
163+ console.log(`[pack-context] 产物:${path.join(OUT_DIR, tgzName)}`)
164+ console.log(`[pack-context] 发布:npm publish ${path.join(OUT_DIR, tgzName)}`)
165+}
166+ 
167+main()
@@ -7,7 +7,7 @@
7// See LICENSE in the root of the software repository for the full text of the License.7// See LICENSE in the root of the software repository for the full text of the License.
8 8 
9import { NextRequest, NextResponse } from 'next/server';9import { NextRequest, NextResponse } from 'next/server';
10-import { listCannbay2Sessions, importCannbay2Session, uploadCannbay2Session, GovernanceError } from '@/lib/cannbay2';10+import { listCannbay2Overview, importCannbay2Session, uploadCannbay2Session, GovernanceError } from '@/lib/cannbay2';
11import { resolveCannbay2Remote, testCannbay2Remote, listRemoteBranches, redactRemoteSecrets, normalizeDirPrefix } from '@/lib/cannbay2/remote';11import { resolveCannbay2Remote, testCannbay2Remote, listRemoteBranches, redactRemoteSecrets, normalizeDirPrefix } from '@/lib/cannbay2/remote';
12import { listRemoteDirs } from '@/lib/cannbay2/mirror';12import { listRemoteDirs } from '@/lib/cannbay2/mirror';
13import type { CannbayRemoteInput } from '@/lib/cannbay-config';13import type { CannbayRemoteInput } from '@/lib/cannbay-config';
@@ -58,8 +58,11 @@ export async function POST(request: NextRequest) {
58 }58 }
59 59 
60 if (action === 'list') {60 if (action === 'list') {
61- const sessions = listCannbay2Sessions(remote);61+ // sessions + 顶层目录(dirs)一次返回:导入对话框的范式下拉 + 会话表
62- return NextResponse.json({ sessions });62+ // 共享单轮 mirror fetch(原先 dirs/list 两路各 fetch 一次)。dirs 字段
63+ // 与 action=dirs 同源(含 sessions/,调用方自行过滤);自定义单根远端为 []
64+ const { sessions, topDirs } = listCannbay2Overview(remote);
65+ return NextResponse.json({ sessions, dirs: topDirs });
63 }66 }
64 67 
65 if (action === 'dirs') {68 if (action === 'dirs') {
@@ -8,7 +8,7 @@
8 8 
9import { NextRequest, NextResponse } from 'next/server';9import { NextRequest, NextResponse } from 'next/server';
10import { importSession } from '@/lib/ingest/data-service';10import { importSession } from '@/lib/ingest/data-service';
11-import { BRAND_SOURCE_TYPE } from '@/lib/branding';11+import { BRAND_SOURCE_TYPE, BAY_NAME } from '@/lib/branding';
12import { prisma } from '@/lib/db';12import { prisma } from '@/lib/db';
13import fs from 'node:fs';13import fs from 'node:fs';
14import path from 'node:path';14import path from 'node:path';
@@ -92,7 +92,7 @@ export async function POST(request: NextRequest) {
92 cloneDir = await ensureCannbayClone();92 cloneDir = await ensureCannbayClone();
93 } catch (cloneErr) {93 } catch (cloneErr) {
94 const msg = cloneErr instanceof Error ? cloneErr.message : 'Clone failed';94 const msg = cloneErr instanceof Error ? cloneErr.message : 'Clone failed';
95- return NextResponse.json({ error: `Failed to clone CANNBay: ${msg}` }, { status: 500 });95+ return NextResponse.json({ error: `Failed to clone ${BAY_NAME}: ${msg}` }, { status: 500 });
96 }96 }
97 97 
98 try {98 try {
@@ -186,7 +186,7 @@ export async function POST(request: NextRequest) {
186 for (const f of filenames) {186 for (const f of filenames) {
187 const fullPath = path.join(cloneDir, f);187 const fullPath = path.join(cloneDir, f);
188 if (!fs.existsSync(fullPath)) {188 if (!fs.existsSync(fullPath)) {
189- results.push({ filename: f, taskId: '', imported: false, query: null, error: 'File not found in CANNBay' });189+ results.push({ filename: f, taskId: '', imported: false, query: null, error: `File not found in ${BAY_NAME}` });
190 continue;190 continue;
191 }191 }
192 192 
@@ -15,6 +15,7 @@ import path from 'node:path';
15import os from 'node:os';15import os from 'node:os';
16import { execSync } from 'node:child_process';16import { execSync } from 'node:child_process';
17import { planArchive, MAX_MASTER_SESSIONS, type FileWithDate } from '@/lib/cannbay-archive';17import { planArchive, MAX_MASTER_SESSIONS, type FileWithDate } from '@/lib/cannbay-archive';
18+import { BAY_NAME } from '@/lib/branding';
18 19 
19const CANNBAY_PULL_URL = 'https://gitcode.com/guanxinghua/CANNBay.git';20const CANNBAY_PULL_URL = 'https://gitcode.com/guanxinghua/CANNBay.git';
20const CANNBAY_PUSH_URL = Buffer.from('aHR0cHM6Ly9ndWFueGluZ2h1YTpwc3F5WXAyYnpFRkI0eDVQRlVTV0dMS3lAZ2l0Y29kZS5jb20vZ3VhbnhpbmdodWEvQ0FOTkJheS5naXQ=', 'base64').toString();21const CANNBAY_PUSH_URL = Buffer.from('aHR0cHM6Ly9ndWFueGluZ2h1YTpwc3F5WXAyYnpFRkI0eDVQRlVTV0dMS3lAZ2l0Y29kZS5jb20vZ3VhbnhpbmdodWEvQ0FOTkJheS5naXQ=', 'base64').toString();
@@ -44,7 +45,7 @@ function listMasterSessions(cwd: string): FileWithDate[] {
44 .map((f) => ({ filename: f, addedAtMs: fileAddedAtMs(f, cwd) }));45 .map((f) => ({ filename: f, addedAtMs: fileAddedAtMs(f, cwd) }));
45}46}
46 47 
47-// Persistent local mirror of CANNBay: full-clone ONCE, then each upload only48+// Persistent local mirror of the data repo: full-clone ONCE, then each upload only
48// `git fetch`es the delta (one new commit + ~one .db blob). Re-cloning ~1.6GB49// `git fetch`es the delta (one new commit + ~one .db blob). Re-cloning ~1.6GB
49// of history every upload was the real perf bottleneck — the 20-cap shrinks50// of history every upload was the real perf bottleneck — the 20-cap shrinks
50// the working tree, but archived files remain in history, so a fresh clone51// the working tree, but archived files remain in history, so a fresh clone
@@ -139,7 +140,7 @@ export async function POST(request: NextRequest) {
139 mirrorDir = await ensureUploadMirror();140 mirrorDir = await ensureUploadMirror();
140 } catch (e) {141 } catch (e) {
141 const msg = e instanceof Error ? e.message : 'Clone failed';142 const msg = e instanceof Error ? e.message : 'Clone failed';
142- return NextResponse.json({ error: `Failed to clone CANNBay mirror: ${msg}` }, { status: 500 });143+ return NextResponse.json({ error: `Failed to clone ${BAY_NAME} mirror: ${msg}` }, { status: 500 });
143 }144 }
144 145 
145 try {146 try {
@@ -19,7 +19,7 @@ import { ArrowLeftIcon, LayoutDashboardIcon, MessageSquareIcon, SearchIcon, Spar
19import { cn } from "@/lib/utils"19import { cn } from "@/lib/utils"
20import { toast } from "sonner"20import { toast } from "sonner"
21import { VERSION_DISPLAY } from "@/lib/version"21import { VERSION_DISPLAY } from "@/lib/version"
22-import { BRAND_NAME } from "@/lib/branding"22+import { BRAND_NAME, BAY_NAME } from "@/lib/branding"
23import type { TurnHighlight } from "@/lib/shared/highlight"23import type { TurnHighlight } from "@/lib/shared/highlight"
24import { TurnTimeline } from "@/components/observe/TurnTimeline"24import { TurnTimeline } from "@/components/observe/TurnTimeline"
25import { TurnDetail } from "@/components/observe/TurnDetail"25import { TurnDetail } from "@/components/observe/TurnDetail"
@@ -538,7 +538,7 @@ export default function SessionDetailPage({
538 const [error, setError] = useState<string | null>(null)538 const [error, setError] = useState<string | null>(null)
539 const [exportingMd, setExportingMd] = useState(false)539 const [exportingMd, setExportingMd] = useState(false)
540 const [exportingHtml, setExportingHtml] = useState(false)540 const [exportingHtml, setExportingHtml] = useState(false)
541- // CANNBay 上传弹窗(共享组件,与主页面上传同款):结果经 toast 呈现541+ // 数据仓上传弹窗(共享组件,与主页面上传同款):结果经 toast 呈现
542 const [cannbayUploadOpen, setCannbayUploadOpen] = useState(false)542 const [cannbayUploadOpen, setCannbayUploadOpen] = useState(false)
543 const [refreshing, setRefreshing] = useState(false)543 const [refreshing, setRefreshing] = useState(false)
544 const refreshingRef = useRef(false)544 const refreshingRef = useRef(false)
@@ -1869,7 +1869,7 @@ export default function SessionDetailPage({
1869 size="sm"1869 size="sm"
1870 className="gap-1 text-muted-foreground"1870 className="gap-1 text-muted-foreground"
1871 onClick={() => setCannbayUploadOpen(true)}1871 onClick={() => setCannbayUploadOpen(true)}
1872- title="Upload to CANNBay"1872+ title={`Upload to ${BAY_NAME}`}
1873 >1873 >
1874 <UploadIcon className="size-4" />1874 <UploadIcon className="size-4" />
1875 </Button>1875 </Button>
@@ -1928,7 +1928,7 @@ export default function SessionDetailPage({
1928 user: session.user,1928 user: session.user,
1929 } : null}1929 } : null}
1930 onResult={({ ok, message }) => {1930 onResult={({ ok, message }) => {
1931- if (ok) toast.success("已上传到 CANNBay")1931+ if (ok) toast.success(`已上传到 ${BAY_NAME}`)
1932 else toast.error("上传失败", { description: message ?? "Unknown error" })1932 else toast.error("上传失败", { description: message ?? "Unknown error" })
1933 }}1933 }}
1934 />1934 />
@@ -11,6 +11,7 @@ import { InsightClient } from '../client';
11import { renderTable, TableColumn } from '../utils/table';11import { renderTable, TableColumn } from '../utils/table';
12import { formatDate } from '../utils/format';12import { formatDate } from '../utils/format';
13import { formatHeader, formatDivider, formatSuccess, formatWarning, theme } from '../utils/colors';13import { formatHeader, formatDivider, formatSuccess, formatWarning, theme } from '../utils/colors';
14+import { BAY_NAME } from '@/lib/branding';
14import type { ApiImportableSession, ApiImportableSessionsResponse } from '../types';15import type { ApiImportableSession, ApiImportableSessionsResponse } from '../types';
15import readline from 'node:readline';16import readline from 'node:readline';
16import { execSync } from 'node:child_process';17import { execSync } from 'node:child_process';
@@ -176,7 +177,7 @@ async function selectPrompt(sessions: ApiImportableSession[]): Promise<ApiImport
176export function uploadCommand(): Command {177export function uploadCommand(): Command {
177 const cmd = new Command('upload');178 const cmd = new Command('upload');
178 cmd179 cmd
179- .description('Upload session to CANNBay (auto-starts backend if needed)')180+ .description(`Upload session to ${BAY_NAME} (auto-starts backend if needed)`)
180 .option('--session <taskId>', 'Upload from Insight DB by taskId')181 .option('--session <taskId>', 'Upload from Insight DB by taskId')
181 .option('--framework <framework>', 'Framework type (auto-detected from file)')182 .option('--framework <framework>', 'Framework type (auto-detected from file)')
182 .option('--source <type>', 'Source type — auto-detected from --file if omitted')183 .option('--source <type>', 'Source type — auto-detected from --file if omitted')
@@ -184,7 +185,7 @@ export function uploadCommand(): Command {
184 .option('--list', 'List importable sessions from source file')185 .option('--list', 'List importable sessions from source file')
185 .option('--session-id <id>', 'Upload specific session from source file by ID')186 .option('--session-id <id>', 'Upload specific session from source file by ID')
186 .option('--description <text>', 'Description text (skip interactive prompt)')187 .option('--description <text>', 'Description text (skip interactive prompt)')
187- .option('--paradigm <dir>', 'Paradigm directory in CANNBay (top-level dir, required for default remote)')188+ .option('--paradigm <dir>', `Paradigm directory in ${BAY_NAME} (top-level dir, required for default remote)`)
188 .option('--baseline', 'Set this session as the paradigm baseline (<paradigm>/baseline/, replaces the current one)')189 .option('--baseline', 'Set this session as the paradigm baseline (<paradigm>/baseline/, replaces the current one)')
189 .option('--interactive', 'Interactive prompt for description fields (default when no --description)')190 .option('--interactive', 'Interactive prompt for description fields (default when no --description)')
190 .option('--yes', 'Skip confirmation prompt')191 .option('--yes', 'Skip confirmation prompt')
@@ -256,7 +257,7 @@ export function uploadCommand(): Command {
256 return;257 return;
257 }258 }
258 259 
259- console.log(formatSuccess(`✓ Uploaded session ${taskId} → CANNBay`));260+ console.log(formatSuccess(`✓ Uploaded session ${taskId} → ${BAY_NAME}`));
260 console.log(theme.muted(` Folder: ${result.folder}${result.unchanged ? ' (unchanged)' : ''}`));261 console.log(theme.muted(` Folder: ${result.folder}${result.unchanged ? ' (unchanged)' : ''}`));
261 return;262 return;
262 }263 }
@@ -349,8 +350,8 @@ export function uploadCommand(): Command {
349 if (!opts.json) console.log(theme.muted(' Importing into Insight DB...'));350 if (!opts.json) console.log(theme.muted(' Importing into Insight DB...'));
350 const importResult = await client.importSession(source, filePath, target.id);351 const importResult = await client.importSession(source, filePath, target.id);
351 352 
352- // Step 2: Upload to CANNBay (taskId lookup happens server-side)353+ // Step 2: 上传到数据仓(taskId lookup happens server-side)
353- if (!opts.json) console.log(theme.muted(' Uploading to CANNBay...'));354+ if (!opts.json) console.log(theme.muted(` Uploading to ${BAY_NAME}...`));
354 const uploadResult = paradigm && baseline355 const uploadResult = paradigm && baseline
355 ? await client.uploadSession(target.id, description, paradigm, true)356 ? await client.uploadSession(target.id, description, paradigm, true)
356 : paradigm357 : paradigm
@@ -362,7 +363,7 @@ export function uploadCommand(): Command {
362 return;363 return;
363 }364 }
364 365 
365- console.log(formatSuccess(`✓ Imported & uploaded session ${target.id} → CANNBay`));366+ console.log(formatSuccess(`✓ Imported & uploaded session ${target.id} → ${BAY_NAME}`));
366 console.log(theme.muted(` Insight taskId: ${importResult.sessionId}`));367 console.log(theme.muted(` Insight taskId: ${importResult.sessionId}`));
367 console.log(theme.muted(` Folder: ${uploadResult.folder}${uploadResult.unchanged ? ' (unchanged)' : ''}`));368 console.log(theme.muted(` Folder: ${uploadResult.folder}${uploadResult.unchanged ? ' (unchanged)' : ''}`));
368 return;369 return;
@@ -7,8 +7,10 @@
7// See LICENSE in the root of the software repository for the full text of the License.7// See LICENSE in the root of the software repository for the full text of the License.
8 8 
9import { Command } from 'commander';9import { Command } from 'commander';
10-import { VERSION } from '@/lib/version';10+import { pathToFileURL } from 'node:url';
11-import { BRAND_SLUG, BRAND_CLI_ALIAS, BRAND_DESCRIPTION } from '@/lib/branding';11+import path from 'node:path';
12+import { VERSION, VERSION_DISPLAY } from '@/lib/version';
13+import { BRAND_NAME, BRAND_SLUG, BRAND_CLI_ALIAS, BRAND_DESCRIPTION } from '@/lib/branding';
12import { DEFAULT_SERVER_URL, loadConfig } from './config';14import { DEFAULT_SERVER_URL, loadConfig } from './config';
13import { runTui } from './tui/App';15import { runTui } from './tui/App';
14import { sessionsCommand } from './commands/sessions';16import { sessionsCommand } from './commands/sessions';
@@ -32,7 +34,10 @@ export function createProgram(): Command {
32 .name(BRAND_SLUG)34 .name(BRAND_SLUG)
33 .alias(BRAND_CLI_ALIAS)35 .alias(BRAND_CLI_ALIAS)
34 .description(BRAND_DESCRIPTION)36 .description(BRAND_DESCRIPTION)
35- .version(VERSION)37+ // '-v, --version':-v 是用户直觉输入(与 cpx 对齐);-V 为 Commander
38+ // 默认旗标不保留(多短旗 Commander 不支持),bin 透传名单仍放行 -V
39+ // 走 Commander 的 unknown option 提示,绝不误入"无参数 → 启动 Web UI"
40+ .version(`${BRAND_NAME} ${VERSION_DISPLAY}`, '-v, --version')
36 .option('--server <url>', 'Backend server URL', DEFAULT_SERVER_URL)41 .option('--server <url>', 'Backend server URL', DEFAULT_SERVER_URL)
37 .option('--timeout <ms>', 'Request timeout in ms', '15000');42 .option('--timeout <ms>', 'Request timeout in ms', '15000');
38 43 
@@ -61,12 +66,26 @@ export function createProgram(): Command {
61 return program;66 return program;
62}67}
63 68 
64-const program = createProgram();69+// 主流程仅"作为入口直跑"时执行(bin tsx 回退与 dist/cli.mjs 均以本文件
65-program.parseAsync(process.argv).catch((err: Error) => {70+// 为入口):以 argv[1] 与 import.meta.url 一致判主模块。不能用 VITEST env
66- if (err instanceof InsightError) {71+// 守卫——npm-publish 测试 spawn 的 dist/cli.mjs 子进程会继承该 env,主流程
67- console.error(`\n❌ ${err.message}`);72+// 被误跳过(--version 空输出)。
68- } else {73+const isCliEntry = (() => {
69- console.error(err);74+ if (!process.argv[1]) return false;
75+ try {
76+ return import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
77+ } catch {
78+ return false;
70 }79 }
71- process.exit(1);80+})();
72-});81+if (isCliEntry) {
82+ const program = createProgram();
83+ program.parseAsync(process.argv).catch((err: Error) => {
84+ if (err instanceof InsightError) {
85+ console.error(`\n❌ ${err.message}`);
86+ } else {
87+ console.error(err);
88+ }
89+ process.exit(1);
90+ });
91+}
@@ -9,6 +9,7 @@
9import { useEffect, useState } from 'react';9import { useEffect, useState } from 'react';
10import { Box, Text, useInput } from 'ink';10import { Box, Text, useInput } from 'ink';
11import { TextInput } from './TextInput';11import { TextInput } from './TextInput';
12+import { BAY_NAME } from '@/lib/branding';
12import type { InsightClient } from '@/cli/client';13import type { InsightClient } from '@/cli/client';
13 14 
14interface UploadPanelProps {15interface UploadPanelProps {
@@ -20,7 +21,7 @@ interface UploadPanelProps {
20 21 
21type Phase = 'paradigm' | 'submitter' | 'uploading';22type Phase = 'paradigm' | 'submitter' | 'uploading';
22 23 
23-// CANNBay 上传面板:① 范式目录选择(数据仓顶层目录即注册表,↑↓ 移动 Enter24+// 数据仓上传面板:① 范式目录选择(数据仓顶层目录即注册表,↑↓ 移动 Enter
24// 确认)→ ② 提交人输入(网关强制非空)→ ③ 上传。Esc 任意阶段取消。25// 确认)→ ② 提交人输入(网关强制非空)→ ③ 上传。Esc 任意阶段取消。
25// 修复旧版 ConfirmDialog 直传空 description 被提交人网关拒绝的缺陷。26// 修复旧版 ConfirmDialog 直传空 description 被提交人网关拒绝的缺陷。
26export function UploadPanel({ client, taskId, onDone, onCancel }: UploadPanelProps) {27export function UploadPanel({ client, taskId, onDone, onCancel }: UploadPanelProps) {
@@ -62,7 +63,7 @@ export function UploadPanel({ client, taskId, onDone, onCancel }: UploadPanelPro
62 paradigm,63 paradigm,
63 baseline || undefined,64 baseline || undefined,
64 );65 );
65- onDone(`Uploaded ${taskId} → CANNBay ${result.folder}${result.unchanged ? ' (unchanged)' : ''}`);66+ onDone(`Uploaded ${taskId} → ${BAY_NAME} ${result.folder}${result.unchanged ? ' (unchanged)' : ''}`);
66 } catch (e) {67 } catch (e) {
67 setUploadError(e instanceof Error ? e.message : String(e));68 setUploadError(e instanceof Error ? e.message : String(e));
68 setPhase('submitter');69 setPhase('submitter');
@@ -81,7 +82,7 @@ export function UploadPanel({ client, taskId, onDone, onCancel }: UploadPanelPro
81 82 
82 return (83 return (
83 <Box flexDirection="column" borderStyle="round" padding={1}>84 <Box flexDirection="column" borderStyle="round" padding={1}>
84- <Text bold>Upload session &quot;{taskId}&quot; to CANNBay</Text>85+ <Text bold>Upload session &quot;{taskId}&quot; to {BAY_NAME}</Text>
85 <Box height={1} />86 <Box height={1} />
86 {phase === 'paradigm' && (87 {phase === 'paradigm' && (
87 loading ? (88 loading ? (
@@ -8,7 +8,7 @@
8// See LICENSE in the root of the software repository for the full text of the License.8// See LICENSE in the root of the software repository for the full text of the License.
9 9 
10import { useState, useEffect, useCallback } from "react"10import { useState, useEffect, useCallback } from "react"
11-import { BRAND_NAME, BRAND_SLUG, BRAND_SOURCE_TYPE } from '@/lib/branding'11+import { BRAND_NAME, BRAND_SLUG, BRAND_SOURCE_TYPE, BAY_NAME } from '@/lib/branding'
12import { loadCannbayConfig, cannbayRemoteInput, isDefaultCannbayRemote, DEFAULT_CANNBAY_ADDRESS } from '@/lib/cannbay-config'12import { loadCannbayConfig, cannbayRemoteInput, isDefaultCannbayRemote, DEFAULT_CANNBAY_ADDRESS } from '@/lib/cannbay-config'
13import {13import {
14 Dialog,14 Dialog,
@@ -342,7 +342,9 @@ export function LocalFileImport() {
342 342 
343 // 范式优先打开:默认仓拉取范式目录清单(注册表)+ 全量会话列表,进入范式343 // 范式优先打开:默认仓拉取范式目录清单(注册表)+ 全量会话列表,进入范式
344 // 选择阶段(选中后本地过滤,切换零延迟)。自定义数据湖远端不启用范式目录344 // 选择阶段(选中后本地过滤,切换零延迟)。自定义数据湖远端不启用范式目录
345- // (后端单根模式,任意顶层目录不是范式注册表)——只拉列表,直接进会话表345+ // (后端单根模式,任意顶层目录不是范式注册表)——只拉列表,直接进会话表。
346+ // list 一次请求同时返回 sessions + dirs(顶层目录):后端单轮 mirror fetch
347+ // 服务两份数据(原先 dirs/list 两路各 fetch 一次 ~1s×2)
346 async function handleOpenCannbay() {348 async function handleOpenCannbay() {
347 setCannbayLoading(true)349 setCannbayLoading(true)
348 setError(null)350 setError(null)
@@ -357,29 +359,21 @@ export function LocalFileImport() {
357 ? `${addr} · 分支 ${cfg?.branch.trim() || "main"}`359 ? `${addr} · 分支 ${cfg?.branch.trim() || "main"}`
358 : `${addr} · 分支 ${cfg?.branch.trim() || "main"} · 目录 /${cfg?.directory.trim() || "sessions"}`)360 : `${addr} · 分支 ${cfg?.branch.trim() || "main"} · 目录 /${cfg?.directory.trim() || "sessions"}`)
359 try {361 try {
360- let paradigms: string[] = []
361- if (paradigmEnabled) {
362- const dirsRes = await fetch("/api/ingest/cannbay2", {
363- method: "POST",
364- headers: { "Content-Type": "application/json" },
365- body: JSON.stringify({ action: "dirs", ...cannbayRemoteInput(cfg) }),
366- })
367- const dirsData = await dirsRes.json()
368- if (!dirsRes.ok) throw new Error(dirsData.error ?? "Failed to list paradigm dirs")
369- paradigms = ((dirsData.dirs ?? []) as string[]).filter(d => d !== "sessions")
370- if (paradigms.length === 0) {
371- setError("数据仓暂无范式目录:请先在数据仓顶层新建范式目录")
372- return
373- }
374- setCannbayParadigms(paradigms)
375- }
376 const listRes = await fetch("/api/ingest/cannbay2", {362 const listRes = await fetch("/api/ingest/cannbay2", {
377 method: "POST",363 method: "POST",
378 headers: { "Content-Type": "application/json" },364 headers: { "Content-Type": "application/json" },
379 body: JSON.stringify({ action: "list", ...cannbayRemoteInput(cfg) }),365 body: JSON.stringify({ action: "list", ...cannbayRemoteInput(cfg) }),
380 })366 })
381 const listData = await listRes.json()367 const listData = await listRes.json()
382- if (!listRes.ok) throw new Error(listData.error ?? "Failed to fetch CANNBay sessions")368+ if (!listRes.ok) throw new Error(listData.error ?? `Failed to fetch ${BAY_NAME} sessions`)
369+ if (paradigmEnabled) {
370+ const paradigms = ((listData.dirs ?? []) as string[]).filter(d => d !== "sessions")
371+ if (paradigms.length === 0) {
372+ setError("数据仓暂无范式目录:请先在数据仓顶层新建范式目录")
373+ return
374+ }
375+ setCannbayParadigms(paradigms)
376+ }
383 setCannbaySessions(listData.sessions ?? [])377 setCannbaySessions(listData.sessions ?? [])
384 // 自定义远端直接进会话表(未标注 = 单根全量);默认仓停在范式选择阶段378 // 自定义远端直接进会话表(未标注 = 单根全量);默认仓停在范式选择阶段
385 setCannbayParadigm(paradigmEnabled ? null : "__none__")379 setCannbayParadigm(paradigmEnabled ? null : "__none__")
@@ -414,7 +408,11 @@ export function LocalFileImport() {
414 setImportStatuses(sids.map(sid => ({ sessionId: sid, status: "pending" as const })))408 setImportStatuses(sids.map(sid => ({ sessionId: sid, status: "pending" as const })))
415 409 
416 const importedPreviews: SessionPreview[] = []410 const importedPreviews: SessionPreview[] = []
417- for (const sid of sids) {411+ 
412+ async function importOne(sid: string) {
413+ setImportStatuses(prev =>
414+ prev.map(s => s.sessionId === sid ? { ...s, status: "importing" as const } : s)
415+ )
418 try {416 try {
419 const res = await fetch("/api/ingest/cannbay2", {417 const res = await fetch("/api/ingest/cannbay2", {
420 method: "POST",418 method: "POST",
@@ -445,6 +443,16 @@ export function LocalFileImport() {
445 : s))443 : s))
446 }444 }
447 }445 }
446+ 
447+ // Concurrent pool(与本地导入同款):物化网络下载并行,wall-time ÷N
448+ const CONCURRENCY = 4
449+ let nextIndex = 0
450+ const workers = Array.from({ length: Math.min(CONCURRENCY, sids.length) }, async () => {
451+ while (nextIndex < sids.length) {
452+ await importOne(sids[nextIndex++])
453+ }
454+ })
455+ await Promise.all(workers)
448 setSessions(prev => [...prev, ...importedPreviews])456 setSessions(prev => [...prev, ...importedPreviews])
449 }457 }
450 458 
@@ -466,7 +474,7 @@ export function LocalFileImport() {
466 : sessions474 : sessions
467 475 
468 function toggleAll() {476 function toggleAll() {
469- // 全选作用于当前过滤视图(与 CANNBay 列表同款语义)477+ // 全选作用于当前过滤视图(与数据仓列表同款语义)
470 if (filteredSessions.length > 0 && filteredSessions.every(s => selectedIds.has(s.id))) {478 if (filteredSessions.length > 0 && filteredSessions.every(s => selectedIds.has(s.id))) {
471 setSelectedIds(prev => {479 setSelectedIds(prev => {
472 const next = new Set(prev)480 const next = new Set(prev)
@@ -541,7 +549,7 @@ export function LocalFileImport() {
541 : DEFAULT_CANNBAY_ADDRESS549 : DEFAULT_CANNBAY_ADDRESS
542 const branch = cfg?.branch.trim() || "main"550 const branch = cfg?.branch.trim() || "main"
543 const dir = cfg?.directory.trim() || "sessions"551 const dir = cfg?.directory.trim() || "sessions"
544- return `CANNBay@${addr.replace(/^https?:\/\//, "")}@${branch}:${dir}/${sid}`552+ return `${BAY_NAME}@${addr.replace(/^https?:\/\//, "")}@${branch}:${dir}/${sid}`
545 }553 }
546 const entries: ImportHistoryEntry[] = importStatuses.map(s => {554 const entries: ImportHistoryEntry[] = importStatuses.map(s => {
547 const session = sessions.find(p => p.id === s.sessionId)555 const session = sessions.find(p => p.id === s.sessionId)
@@ -584,7 +592,7 @@ export function LocalFileImport() {
584 <DialogHeader>592 <DialogHeader>
585 <DialogTitle>Import Session</DialogTitle>593 <DialogTitle>Import Session</DialogTitle>
586 <DialogDescription>594 <DialogDescription>
587- {step === "input" && "Import session data from local files or CANNBay"}595+ {step === "input" && `Import session data from local files or ${BAY_NAME}`}
588 {step === "browse" && "Browse directories and select a file to import"}596 {step === "browse" && "Browse directories and select a file to import"}
589 {step === "select" && "Select sessions to import"}597 {step === "select" && "Select sessions to import"}
590 {step === "cannbay" && (cannbayParadigm == null ? "选择范式目录(数据仓顶层目录)" : "选择该范式下的会话导入")}598 {step === "cannbay" && (cannbayParadigm == null ? "选择范式目录(数据仓顶层目录)" : "选择该范式下的会话导入")}
@@ -617,7 +625,7 @@ export function LocalFileImport() {
617 onClick={() => { setSourceType("cannbay"); handleOpenCannbay() }}625 onClick={() => { setSourceType("cannbay"); handleOpenCannbay() }}
618 >626 >
619 <GlobeIcon className="size-3.5 mr-1" />627 <GlobeIcon className="size-3.5 mr-1" />
620- CANNBay628+ {BAY_NAME}
621 </Button>629 </Button>
622 </div>630 </div>
623 </div>631 </div>
@@ -690,14 +698,14 @@ export function LocalFileImport() {
690 {sourceType === "cannbay" && !cannbayLoading && cannbayParadigms.length === 0 && !error && (698 {sourceType === "cannbay" && !cannbayLoading && cannbayParadigms.length === 0 && !error && (
691 <div className="text-sm text-muted-foreground py-4 text-center">699 <div className="text-sm text-muted-foreground py-4 text-center">
692 <GlobeIcon className="size-5 mx-auto mb-2 text-blue-500" />700 <GlobeIcon className="size-5 mx-auto mb-2 text-blue-500" />
693- <p>从 CANNBay 数据仓选择范式目录,导入该范式下的会话</p>701+ <p>从 {BAY_NAME} 数据仓选择范式目录,导入该范式下的会话</p>
694 <p className="text-xs mt-1">范式目录 = 数据仓顶层目录;其他用户上传的会话按范式归类</p>702 <p className="text-xs mt-1">范式目录 = 数据仓顶层目录;其他用户上传的会话按范式归类</p>
695 </div>703 </div>
696 )}704 )}
697 {sourceType === "cannbay" && cannbayLoading && (705 {sourceType === "cannbay" && cannbayLoading && (
698 <div className="flex items-center gap-2 py-4 justify-center">706 <div className="flex items-center gap-2 py-4 justify-center">
699 <LoaderIcon className="size-4 animate-spin" />707 <LoaderIcon className="size-4 animate-spin" />
700- <span className="text-sm text-muted-foreground">Fetching sessions from CANNBay...</span>708+ <span className="text-sm text-muted-foreground">Fetching sessions from {BAY_NAME}...</span>
701 </div>709 </div>
702 )}710 )}
703 {error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}711 {error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
@@ -1088,7 +1096,7 @@ export function LocalFileImport() {
1088 )}1096 )}
1089 {step === "input" && sourceType === "cannbay" && (1097 {step === "input" && sourceType === "cannbay" && (
1090 <Button onClick={handleOpenCannbay} disabled={cannbayLoading}>1098 <Button onClick={handleOpenCannbay} disabled={cannbayLoading}>
1091- {cannbayLoading ? "Fetching..." : "Fetch from CANNBay"}1099+ {cannbayLoading ? "Fetching..." : `Fetch from ${BAY_NAME}`}
1092 </Button>1100 </Button>
1093 )}1101 )}
1094 {step === "select" && (1102 {step === "select" && (
@@ -10,7 +10,7 @@
10 10 
11import { useEffect, useRef, useState } from 'react'11import { useEffect, useRef, useState } from 'react'
12import Link from 'next/link'12import Link from 'next/link'
13-import { BRAND_NAME, BRAND_SOURCE_TYPE } from '@/lib/branding'13+import { BRAND_NAME, BRAND_SOURCE_TYPE, BAY_NAME } from '@/lib/branding'
14import { isProxyVersion } from '@/lib/shared/session-format'14import { isProxyVersion } from '@/lib/shared/session-format'
15import { useRouter } from 'next/navigation';15import { useRouter } from 'next/navigation';
16import {16import {
@@ -551,7 +551,7 @@ export function SessionList({ items: initialItems, total: initialTotal, page: in
551 className="size-6"551 className="size-6"
552 onClick={() => openUploadDialog(item)}552 onClick={() => openUploadDialog(item)}
553 disabled={uStatus === 'uploading'}553 disabled={uStatus === 'uploading'}
554- title="Upload to CANNBay"554+ title={`Upload to ${BAY_NAME}`}
555 >555 >
556 {uStatus === 'idle' && <UploadIcon className="size-3.5" />}556 {uStatus === 'idle' && <UploadIcon className="size-3.5" />}
557 {uStatus === 'uploading' && <LoaderIcon className="size-3.5 animate-spin" />}557 {uStatus === 'uploading' && <LoaderIcon className="size-3.5 animate-spin" />}
@@ -7,13 +7,15 @@
7// INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.7// INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
8// See LICENSE in the root of the software repository for the full text of the License.8// See LICENSE in the root of the software repository for the full text of the License.
9 9 
10-// CANNBay 上传弹窗(共享组件):主页列表行与 会话页标题栏 两处入口共用同一10+// 数据仓上传弹窗(共享组件):主页列表行与 会话页标题栏 两处入口共用同一
11// 弹窗(范式目录 / 提交人 / 内容描述 / 算子生成结果必选 / baseline)。受控11// 弹窗(范式目录 / 提交人 / 内容描述 / 算子生成结果必选 / baseline)。受控
12// 组件——open/onOpenChange 由调用方管理,上传开始与结果经 onUploadStart /12// 组件——open/onOpenChange 由调用方管理,上传开始与结果经 onUploadStart /
13// onResult 回调交还调用方呈现(列表行状态图标 / 会话页 toast)。13// onResult 回调交还调用方呈现(列表行状态图标 / 会话页 toast)。
14+// 弹窗标题用 BAY_NAME(@/lib/branding)——context 双名发布时 Bay 显示名随品牌翻转。
14 15 
15import { useEffect, useState } from 'react'16import { useEffect, useState } from 'react'
16import { loadCannbayConfig, cannbayRemoteInput, isDefaultCannbayRemote } from '@/lib/cannbay-config'17import { loadCannbayConfig, cannbayRemoteInput, isDefaultCannbayRemote } from '@/lib/cannbay-config'
18+import { BAY_NAME } from '@/lib/branding'
17import { Button } from '@/components/ui/button'19import { Button } from '@/components/ui/button'
18import { Checkbox } from '@/components/ui/checkbox'20import { Checkbox } from '@/components/ui/checkbox'
19import {21import {
@@ -83,7 +85,7 @@ export function UploadToCannbayDialog({ open, onOpenChange, target, onUploadStar
83 const [uploadSubmitterTouched, setUploadSubmitterTouched] = useState(false)85 const [uploadSubmitterTouched, setUploadSubmitterTouched] = useState(false)
84 const [uploadCategory, setUploadCategory] = useState('')86 const [uploadCategory, setUploadCategory] = useState('')
85 const [uploadBaseline, setUploadBaseline] = useState(false)87 const [uploadBaseline, setUploadBaseline] = useState(false)
86- // 算子生成结果标注(随会话 meta 入仓,CANNBay 列表"算子生成"列展示):88+ // 算子生成结果标注(随会话 meta 入仓,数据仓列表"算子生成"列展示):
87 // Web UI 必选(成功/失败,未选禁用上传);'' 仅作初始未选态。数据层仍允许89 // Web UI 必选(成功/失败,未选禁用上传);'' 仅作初始未选态。数据层仍允许
88 // 缺省(CLI 上传/历史数据 → 列表显示 —)90 // 缺省(CLI 上传/历史数据 → 列表显示 —)
89 const [uploadOpGen, setUploadOpGen] = useState<'' | 'success' | 'failure'>('')91 const [uploadOpGen, setUploadOpGen] = useState<'' | 'success' | 'failure'>('')
@@ -165,7 +167,7 @@ export function UploadToCannbayDialog({ open, onOpenChange, target, onUploadStar
165 <Dialog open={open} onOpenChange={(v) => { if (!v || !uploading) onOpenChange(v) }}>167 <Dialog open={open} onOpenChange={(v) => { if (!v || !uploading) onOpenChange(v) }}>
166 <DialogContent className="sm:max-w-md">168 <DialogContent className="sm:max-w-md">
167 <DialogHeader>169 <DialogHeader>
168- <DialogTitle>上传到 CANNBay</DialogTitle>170+ <DialogTitle>上传到 {BAY_NAME}</DialogTitle>
169 <DialogDescription>会话经数据治理后上传至数据仓,可随时重新导入</DialogDescription>171 <DialogDescription>会话经数据治理后上传至数据仓,可随时重新导入</DialogDescription>
170 </DialogHeader>172 </DialogHeader>
171 {defaultRemote && (173 {defaultRemote && (
@@ -6,7 +6,7 @@
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-// CANNBay retention policy: master holds at most the N newest session .db files;9+// Data repo retention policy: master holds at most the N newest session .db files;
10// older ones rotate off master onto a date-bucketed archive branch. Upload and10// older ones rotate off master onto a date-bucketed archive branch. Upload and
11// download only ever touch master, so capping it keeps clone/push bounded.11// download only ever touch master, so capping it keeps clone/push bounded.
12 12 
@@ -23,6 +23,8 @@ export interface CannbayRemoteInput {
23 token?: string23 token?: string
24 branch?: string24 branch?: string
25 directory?: string25 directory?: string
26+ /** LFS 模式(数据仓须启用 LFS):.jsonl.gz 走 LFS 指针出仓,容量脱离 git 仓配额 */
27+ lfs?: boolean
26}28}
27 29 
28export interface CannbayConfig extends CannbayRemoteInput {30export interface CannbayConfig extends CannbayRemoteInput {
@@ -13,7 +13,7 @@ import fs from 'node:fs';
13import path from 'node:path';13import path from 'node:path';
14import os from 'node:os';14import os from 'node:os';
15import zlib from 'node:zlib';15import zlib from 'node:zlib';
16-import { listSessions, materializeSession, uploadFolder, listParadigmDirs, type Cannbay2Remote, type Cannbay2SessionEntry, type UploadResult } from './mirror';16+import { listSessions, listOverview, materializeSession, uploadFolder, listParadigmDirs, type Cannbay2Remote, type Cannbay2SessionEntry, type Cannbay2Overview, type UploadResult } from './mirror';
17import { readCcSessionMeta } from '../ingest/proxy-classify';17import { readCcSessionMeta } from '../ingest/proxy-classify';
18import { isProxyVersion } from '../shared/session-format';18import { isProxyVersion } from '../shared/session-format';
19import { defaultCannbay2Remote } from './remote';19import { defaultCannbay2Remote } from './remote';
@@ -22,11 +22,15 @@ import { exportSessionToClaudeJsonl, xCannbay } from './export';
22import { importSession } from '@/lib/ingest/data-service';22import { importSession } from '@/lib/ingest/data-service';
23import type { PrismaClient } from '@prisma/client';23import type { PrismaClient } from '@prisma/client';
24 24 
25-export type { Cannbay2SessionEntry };25+export type { Cannbay2SessionEntry, Cannbay2Overview };
26 26 
27// atomgit 单文件上限兜底(按压缩后字节)。导出为可变引用(测试注入小阈值27// atomgit 单文件上限兜底(按压缩后字节)。导出为可变引用(测试注入小阈值
28-// 验证熔断路径;生产恒为 100MB)28+// 验证熔断路径);生产初值可经 env CANNBAY2_MAX_SESSION_MB 覆写(LFS 模式
29-export const MAX_SESSION_BYTES = { value: 100 * 1024 * 1024 };29+// 下平台限制在 LFS 对象侧,git blob 的 100MiB 钳制不再适用)
30+export const MAX_SESSION_BYTES = { value: (() => {
31+ const v = Number(process.env.CANNBAY2_MAX_SESSION_MB);
32+ return Number.isFinite(v) && v > 0 ? Math.floor(v * 1024 * 1024) : 100 * 1024 * 1024;
33+})() };
30 34 
31export class GovernanceError extends Error {35export class GovernanceError extends Error {
32 constructor(message: string) {36 constructor(message: string) {
@@ -41,6 +45,11 @@ export function listCannbay2Sessions(remote?: Cannbay2Remote): Cannbay2SessionEn
41 return listSessions(remote);45 return listSessions(remote);
42}46}
43 47 
48+/** 列表 + 顶层目录合并读取(单轮 mirror fetch 服务导入对话框的范式下拉+会话表)。 */
49+export function listCannbay2Overview(remote?: Cannbay2Remote): Cannbay2Overview {
50+ return listOverview(remote);
51+}
52+ 
44// patch 后从 DB 回读准确的 framework/version(meta 缺失时管线推断的兜底值),53// patch 后从 DB 回读准确的 framework/version(meta 缺失时管线推断的兜底值),
45// 供导入完成态显示类型徽标 —— 与本地导入路径的响应形状对齐。54// 供导入完成态显示类型徽标 —— 与本地导入路径的响应形状对齐。
46async function readSessionType(prisma: PrismaLike, taskId: string): Promise<{ framework: string | null; version: string | null }> {55async function readSessionType(prisma: PrismaLike, taskId: string): Promise<{ framework: string | null; version: string | null }> {
@@ -169,7 +178,7 @@ interface SessionRow {
169// 收集待传文件到 staging 的 sessions/<sid>/ 布局,返回 staging 根目录。178// 收集待传文件到 staging 的 sessions/<sid>/ 布局,返回 staging 根目录。
170// - sourcePath 是本机存在的 jsonl(proxy 捕获 / native claude):原文件直传,179// - sourcePath 是本机存在的 jsonl(proxy 捕获 / native claude):原文件直传,
171// subagents 在 <dirname>/<fileSid>/subagents/(proxy 落盘约定)180// subagents 在 <dirname>/<fileSid>/subagents/(proxy 落盘约定)
172-// - 其余一律 DB 导出兜底:opencode(无捕获文件)、v1 CANNBay 下载的 .db 快照181+// - 其余一律 DB 导出兜底:opencode(无捕获文件)、v1 数据仓下载的 .db 快照
173// 会话(sourcePath 是 .db)、源文件已被删除/移动的会话。导出件不含182// 会话(sourcePath 是 .db)、源文件已被删除/移动的会话。导出件不含
174// Full Context(system/tools),管线数据齐全。183// Full Context(system/tools),管线数据齐全。
175async function stageSessionFiles(session: SessionRow, prisma: PrismaLike): Promise<{ stagingDir: string; sid: string }> {184async function stageSessionFiles(session: SessionRow, prisma: PrismaLike): Promise<{ stagingDir: string; sid: string }> {
@@ -279,7 +288,7 @@ function governStagedSession(stagingDir: string, sid: string): void {
279 let totalBytes = 0;288 let totalBytes = 0;
280 for (const rel of jsonlFiles) totalBytes += fs.statSync(path.join(sessionDir, rel)).size;289 for (const rel of jsonlFiles) totalBytes += fs.statSync(path.join(sessionDir, rel)).size;
281 if (totalBytes > MAX_SESSION_BYTES.value) {290 if (totalBytes > MAX_SESSION_BYTES.value) {
282- throw new GovernanceError(`Session "${sid}" exceeds single-file size limit (${(totalBytes / 1024 / 1024).toFixed(1)}MB > 100MB)`);291+ throw new GovernanceError(`Session "${sid}" exceeds single-file size limit (${(totalBytes / 1024 / 1024).toFixed(1)}MB > ${(MAX_SESSION_BYTES.value / 1024 / 1024).toFixed(0)}MB)`);
283 }292 }
284 return;293 return;
285 }294 }
@@ -311,7 +320,7 @@ function governStagedSession(stagingDir: string, sid: string): void {
311 totalBytes += fs.statSync(gzFile).size;320 totalBytes += fs.statSync(gzFile).size;
312 }321 }
313 if (totalBytes > MAX_SESSION_BYTES.value) {322 if (totalBytes > MAX_SESSION_BYTES.value) {
314- throw new GovernanceError(`Session "${sid}" exceeds single-file size limit (${(totalBytes / 1024 / 1024).toFixed(1)}MB > 100MB,gzip 压缩后仍超限)`);323+ throw new GovernanceError(`Session "${sid}" exceeds single-file size limit (${(totalBytes / 1024 / 1024).toFixed(1)}MB > ${(MAX_SESSION_BYTES.value / 1024 / 1024).toFixed(0)}MB,gzip 压缩后仍超限)`);
315 }324 }
316}325}
317 326 
@@ -7,13 +7,16 @@
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 git 镜像层:partial clone(--filter=blob:none)单镜像上传下载共用。9// cannbay2 git 镜像层:partial clone(--filter=blob:none)单镜像上传下载共用。
10-// 列表 = ls-tree + 一次 git log walk(零 blob 下载);导入 = 按会话 cat-file10+// 列表 = ls-tree + 一次 git log walk + meta 批量读(缺失 blob 先批量预取,
11-// 物化(重排成 proxy 布局);上传 = narrow sparse-checkout + scoped add +11+// 一轮 fetch 协商拉齐;listOverview 让列表+顶层目录共享单轮 fetch);导入 =
12-// commit + push main。写操作进程内 mutex 串行。测试通过12+// 缺失 blob 批量预取后 cat-file --batch 分批物化(重排成 proxy 布局);
13+// 上传 = narrow sparse-checkout + scoped add + commit + push main。
14+// 写操作进程内 mutex 串行。测试通过
13// CANNBAY2_REMOTE_URL / CANNBAY2_PUSH_URL / CANNBAY2_CACHE_DIR 指向本地 bare 仓。15// CANNBAY2_REMOTE_URL / CANNBAY2_PUSH_URL / CANNBAY2_CACHE_DIR 指向本地 bare 仓。
14import { execSync, spawnSync } from 'node:child_process';16import { execSync, spawnSync } from 'node:child_process';
15import { randomUUID } from 'node:crypto';17import { randomUUID } from 'node:crypto';
16import fs from 'node:fs';18import fs from 'node:fs';
19+import os from 'node:os';
17import path from 'node:path';20import path from 'node:path';
18import { defaultCannbay2Remote, escapeRegExp, normalizeDirPrefix, stripUrlCreds } from './remote';21import { defaultCannbay2Remote, escapeRegExp, normalizeDirPrefix, stripUrlCreds } from './remote';
19import type { Cannbay2Remote } from './remote';22import type { Cannbay2Remote } from './remote';
@@ -35,8 +38,11 @@ export function cannbay2CacheDir(remote?: Cannbay2Remote): string {
35// required=false → 即便 filter 配置异常也放行不炸38// required=false → 即便 filter 配置异常也放行不炸
36const LFS_NEUTRALIZE = '-c filter.lfs.process= -c filter.lfs.smudge=cat -c filter.lfs.clean=cat -c filter.lfs.required=false';39const LFS_NEUTRALIZE = '-c filter.lfs.process= -c filter.lfs.smudge=cat -c filter.lfs.clean=cat -c filter.lfs.required=false';
37 40 
38-export function runGit(cmd: string, cwd: string, timeoutMs = 120_000): Buffer {41+// raw = 跳过中和(LFS 模式的 add/push/物化专用:clean 出指针、pre-push 钩子
39- const neutral = cmd.startsWith('git ')42+// 上传 LFS 对象、smudge 按需拉实体);其余命令保持中和(checkout 的
43+// smudge=cat 让既有 LFS 文件落指针文本——clean 对指针透传,re-add 不炸)
44+export function runGit(cmd: string, cwd: string, timeoutMs = 120_000, opts?: { raw?: boolean }): Buffer {
45+ const neutral = !opts?.raw && cmd.startsWith('git ')
40 ? `git ${LFS_NEUTRALIZE} ${cmd.slice('git '.length)}`46 ? `git ${LFS_NEUTRALIZE} ${cmd.slice('git '.length)}`
41 : cmd;47 : cmd;
42 return execSync(neutral, { cwd, stdio: 'pipe', timeout: timeoutMs, maxBuffer: 256 * 1024 * 1024 });48 return execSync(neutral, { cwd, stdio: 'pipe', timeout: timeoutMs, maxBuffer: 256 * 1024 * 1024 });
@@ -46,6 +52,32 @@ function runGitText(cmd: string, cwd: string): string {
46 return runGit(cmd, cwd).toString();52 return runGit(cmd, cwd).toString();
47}53}
48 54 
55+// ── blob 批量预取 ────────────────────────────────────────────────────
56+// partial clone(--filter=blob:none)下 blob 按需懒取:每个缺失对象一次独立
57+// HTTPS 协商(atomgit 实测 ~1s/个)。批量预取 = 一轮 git fetch <oids> 协商拉齐
58+// 全部缺失(实测 1601 个 meta 9.3s,懒取需 ~28min),与懒取走同一服务端能力
59+// (uploadpack.allowAnySHA1InWant;atomgit 支持,本地 bare fixture 同配)。
60+// 任何失败静默跳过 —— 后续逐对象 cat-file 懒取路径不变,行为不劣化。
61+ 
62+// 单次 fetch 的 OID 数(命令行长度安全:200 × 41B ≈ 8KB,Windows 限制内)
63+const FETCH_OID_CHUNK = 200;
64+ 
65+/** 本地已落库对象清单(--batch-all-objects 只列本地,不触发懒取)。 */
66+export function localObjectOids(dir: string): Set<string> {
67+ const out = runGit("git cat-file --batch-all-objects --batch-check='%(objectname)'", dir);
68+ return new Set(out.toString('utf8').split('\n').map(l => l.trim()).filter(Boolean));
69+}
70+ 
71+/** 把缺失 blob 分块批量拉齐。oid 白名单校验(拼进 shell 命令)。 */
72+export function prefetchBlobs(dir: string, oids: string[]): void {
73+ const valid = oids.filter(o => /^[0-9a-f]{40,64}$/.test(o));
74+ for (let i = 0; i < valid.length; i += FETCH_OID_CHUNK) {
75+ try {
76+ runGit(`git fetch --quiet origin ${valid.slice(i, i + FETCH_OID_CHUNK).join(' ')}`, dir, 300_000);
77+ } catch { /* 服务端拒绝按 OID fetch 等 → 回退逐对象懒取 */ }
78+ }
79+}
80+ 
49// ── 镜像生命周期 ───────────────────────────────────────────────────────81// ── 镜像生命周期 ───────────────────────────────────────────────────────
50 82 
51// 跨进程初始化锁:模块级 writeLock 只在单进程内有效,next dev 多 worker /83// 跨进程初始化锁:模块级 writeLock 只在单进程内有效,next dev 多 worker /
@@ -279,6 +311,12 @@ function readSessionMetas(dir: string, remote: Cannbay2Remote): Map<string, { fr
279 if (pathMatch) wanted.push({ sid: pathMatch[1], oid: oidMatch[1] });311 if (pathMatch) wanted.push({ sid: pathMatch[1], oid: oidMatch[1] });
280 }312 }
281 if (wanted.length === 0) return out;313 if (wanted.length === 0) return out;
314+ // 缺失 meta blob 批量预取:一轮 fetch 协商拉齐(懒取是逐对象一次协商,
315+ // 千级缺失时列表被 120s 超时截断、type 列残缺;实测 2370 缺失 ≈ 42min
316+ // 懒取 vs ~10s 批量)。失败回退懒取。
317+ const local = localObjectOids(dir);
318+ const missing = wanted.filter(w => !local.has(w.oid)).map(w => w.oid);
319+ if (missing.length > 0) prefetchBlobs(dir, missing);
282 // spawnSync(非 execSync):execSync 自定义 stdio 时不返回 stdout,且输入320 // spawnSync(非 execSync):execSync 自定义 stdio 时不返回 stdout,且输入
283 // 走 stdin 需要独立 pipe —— 这里要求拿回完整批量输出 buffer321 // 走 stdin 需要独立 pipe —— 这里要求拿回完整批量输出 buffer
284 const batch = spawnSync('git', ['cat-file', '--batch'],322 const batch = spawnSync('git', ['cat-file', '--batch'],
@@ -315,7 +353,10 @@ function readSessionMetas(dir: string, remote: Cannbay2Remote): Map<string, { fr
315 353 
316/** ls-tree + 一次 git log walk + 一次批量 meta 读取 → 全部会话文件夹及提交元数据。 */354/** ls-tree + 一次 git log walk + 一次批量 meta 读取 → 全部会话文件夹及提交元数据。 */
317export function listSessions(remote: Cannbay2Remote = defaultCannbay2Remote()): Cannbay2SessionEntry[] {355export function listSessions(remote: Cannbay2Remote = defaultCannbay2Remote()): Cannbay2SessionEntry[] {
318- const dir = ensureMirror(remote);356+ return listSessionsIn(ensureMirror(remote), remote);
357+}
358+ 
359+function listSessionsIn(dir: string, remote: Cannbay2Remote): Cannbay2SessionEntry[] {
319 const mainRev = revParseMain(dir, remote);360 const mainRev = revParseMain(dir, remote);
320 if (!mainRev) return [];361 if (!mainRev) return [];
321 362 
@@ -387,7 +428,10 @@ export function listSessions(remote: Cannbay2Remote = defaultCannbay2Remote()):
387export function listRemoteDirs(remote: Cannbay2Remote = defaultCannbay2Remote(), prefix = ''): string[] {428export function listRemoteDirs(remote: Cannbay2Remote = defaultCannbay2Remote(), prefix = ''): string[] {
388 // 入口校验(与 uploadFolder 对 sid 同款双重防线):prefix 拼进 shell 命令429 // 入口校验(与 uploadFolder 对 sid 同款双重防线):prefix 拼进 shell 命令
389 const p = normalizeDirPrefix(prefix);430 const p = normalizeDirPrefix(prefix);
390- const dir = ensureMirror(remote);431+ return listDirsIn(ensureMirror(remote), remote, p);
432+}
433+ 
434+function listDirsIn(dir: string, remote: Cannbay2Remote, p: string): string[] {
391 if (!revParseMain(dir, remote)) return [];435 if (!revParseMain(dir, remote)) return [];
392 const spec = p ? ` -- ${p}/` : '';436 const spec = p ? ` -- ${p}/` : '';
393 const files = lsTreeFiles(dir, spec, remote);437 const files = lsTreeFiles(dir, spec, remote);
@@ -400,6 +444,23 @@ export function listRemoteDirs(remote: Cannbay2Remote = defaultCannbay2Remote(),
400 return [...dirs].sort();444 return [...dirs].sort();
401}445}
402 446 
447+export interface Cannbay2Overview {
448+ sessions: Cannbay2SessionEntry[];
449+ /** 顶层目录清单(与 listRemoteDirs('') 同源,含存量区 sessions;自定义单根远端恒 [])。
450+ * 导入对话框的范式下拉与会话列表一次请求共享一轮 mirror fetch(原来 dirs+list
451+ * 两路各 fetch 一次 ~1s×2)。 */
452+ topDirs: string[];
453+}
454+ 
455+/** 会话列表 + 顶层目录合并读取:单轮 ensureMirror fetch 服务两份数据。 */
456+export function listOverview(remote: Cannbay2Remote = defaultCannbay2Remote()): Cannbay2Overview {
457+ const dir = ensureMirror(remote);
458+ return {
459+ sessions: listSessionsIn(dir, remote),
460+ topDirs: remote.paradigmRoots ? listDirsIn(dir, remote, '') : [],
461+ };
462+}
463+ 
403// ── 范式目录注册表(目录即范式,insight 零范式知识)──────────────────────464// ── 范式目录注册表(目录即范式,insight 零范式知识)──────────────────────
404 465 
405/** 顶层范式目录清单(上传下拉数据源 + 网关动态白名单):排除存量区 sessions。466/** 顶层范式目录清单(上传下拉数据源 + 网关动态白名单):排除存量区 sessions。
@@ -449,6 +510,60 @@ function locateBaselineSids(dir: string, remote: Cannbay2Remote, paradigm: strin
449 510 
450// ── 导入物化(cat-file,按需下载 blob) ─────────────────────────────────511// ── 导入物化(cat-file,按需下载 blob) ─────────────────────────────────
451 512 
513+// 非 LFS 批量物化:单进程 --batch-check 取各 blob 尺寸(仅头部输出)→ 按
514+// 累计体积分批(≤64MB/批,有界内存)→ 每批单进程 --batch 流式写盘。预取
515+// 后全本地,省去逐文件 cat-file spawn(334 文件会话 3.8s → ~0.4s)。
516+// batch 应答里 missing / 非 blob 的条目 → 逐文件 cat-file -p 兜底(懒取)。
517+const MATERIALIZE_BATCH_BYTES = 64 * 1024 * 1024;
518+const MATERIALIZE_BATCH_COUNT = 128;
519+ 
520+function materializeBlobsByBatch(dir: string, planned: Array<{ oid: string; target: string }>): void {
521+ const sizeRes = spawnSync('git', ['cat-file', '--batch-check'],
522+ { cwd: dir, input: planned.map(p => `${p.oid}\n`).join(''), maxBuffer: 16 * 1024 * 1024, timeout: 120_000 });
523+ const sizes = new Map<string, number>();
524+ for (const line of (sizeRes.stdout ?? Buffer.alloc(0)).toString('utf8').split('\n')) {
525+ const m = line.match(/^([0-9a-f]+) \w+ (\d+)$/);
526+ if (m) sizes.set(m[1], parseInt(m[2], 10));
527+ }
528+ const skipped: Array<{ oid: string; target: string }> = [];
529+ let batch: Array<{ oid: string; target: string }> = [];
530+ let batchBytes = 0;
531+ const flush = (): void => {
532+ if (batch.length === 0) return;
533+ const res = spawnSync('git', ['cat-file', '--batch'],
534+ { cwd: dir, input: batch.map(p => `${p.oid}\n`).join(''), maxBuffer: 512 * 1024 * 1024, timeout: 300_000 });
535+ const buf = res.stdout ?? Buffer.alloc(0);
536+ let offset = 0;
537+ for (const p of batch) {
538+ const headerEnd = buf.indexOf(0x0a, offset);
539+ if (headerEnd < 0) break;
540+ const header = buf.toString('utf8', offset, headerEnd);
541+ const m = header.match(/^[0-9a-f]+ blob (\d+)$/);
542+ if (!m) {
543+ skipped.push(p);
544+ offset = headerEnd + 1;
545+ continue;
546+ }
547+ const size = parseInt(m[1], 10);
548+ // Buffer.subarray 二进制安全(gzip blob 原样落盘)
549+ fs.writeFileSync(p.target, buf.subarray(headerEnd + 1, headerEnd + 1 + size));
550+ offset = headerEnd + 1 + size + 1;
551+ }
552+ batch = [];
553+ batchBytes = 0;
554+ };
555+ for (const p of planned) {
556+ const sz = sizes.get(p.oid) ?? 0;
557+ if (batch.length >= MATERIALIZE_BATCH_COUNT || (batchBytes + sz > MATERIALIZE_BATCH_BYTES && batch.length > 0)) flush();
558+ batch.push(p);
559+ batchBytes += sz;
560+ }
561+ flush();
562+ for (const p of skipped) {
563+ fs.writeFileSync(p.target, runGit(`git cat-file -p ${p.oid}`, dir));
564+ }
565+}
566+ 
452/**567/**
453 * 把 <root>[/baseline]/<sid>/ 物化到 <cache>/materialized/,重排成 proxy 布局568 * 把 <root>[/baseline]/<sid>/ 物化到 <cache>/materialized/,重排成 proxy 布局
454 * (<sid>.jsonl 与 <sid>/subagents/ 并列 —— claude-jsonl adapter 的约定)。569 * (<sid>.jsonl 与 <sid>/subagents/ 并列 —— claude-jsonl adapter 的约定)。
@@ -459,6 +574,26 @@ export function materializeSession(sid: string, remote: Cannbay2Remote = default
459 if (!revParseMain(dir, remote)) throw new Error('cannbay2 mirror has no main branch');574 if (!revParseMain(dir, remote)) throw new Error('cannbay2 mirror has no main branch');
460 if (!/^[\w.-]+$/.test(sid)) throw new Error(`Invalid session id: "${sid}"`);575 if (!/^[\w.-]+$/.test(sid)) throw new Error(`Invalid session id: "${sid}"`);
461 576 
577+ // LFS 模式:.jsonl.gz 仓内是指针 blob,cat-file 须带 --filters 让 smudge
578+ // 按需拉取 LFS 实体(本地 .git/lfs/objects 缓存,重复导入零下载)。
579+ // --filters 的属性解析读工作树 .gitattributes——读路径镜像默认无 checkout,
580+ // 需从 origin/main 物化一份(上传路径的 sparse 流程本就含 /.gitattributes)。
581+ // 判定对齐 ensureLfsGitattributes(缺 *.gz filter=lfs 声明即刷新,不是仅看
582+ // 文件存在):同一镜像默认模式上传会把剔除过 lfs 行的 .gitattributes 写回
583+ // 工作树(LFS 免疫操作),此后远端切回 LFS 模式时过期副本会让 --filters
584+ // 不做 smudge——指针文本被当作 gzip 内容落盘,导入在解压环节报费解错误
585+ if (remote.lfs) {
H
Hhangdu26 天前

if (!fs.existsSync(gaPath)) 只在工作树无 .gitattributes 时才从 origin/main 物化,但同一镜像的工作树可能留有过期的剔除版:默认模式的 uploadFolder 会在本镜像 sparse-checkout 出的 .gitattributes 里剔除 filter=lfs 行并写回工作树(else if 分支),之后 cpx 以 LFS 模式上传会把该行重新加回 origin/main。此时 insight 侧设 CANNBAY2_LFS=1 导入指针会话:工作树 .gitattributes 存在但缺 *.gz filter=lfs 声明 → cat-file --filters 不做 smudge → LFS 指针文本被当作 gzip 内容落盘,导入在解压环节报出费解错误。建议与 ensureLfsGitattributes 的判定对齐:LFS 模式下只要工作树副本缺 ^\*\.gz\s+filter=lfs 声明就刷新(用 git show origin/main:.gitattributes 覆盖),而不是仅看文件是否存在。

likedislike
hangdu
26 天前 评论:
586+ ensureLfsReady(dir);
587+ const gaPath = path.join(dir, '.gitattributes');
588+ let gaCur = '';
589+ try { gaCur = fs.readFileSync(gaPath, 'utf8'); } catch { /* 无文件 */ }
590+ if (!/^\*\.gz\s+filter=lfs/m.test(gaCur)) {
591+ try {
592+ fs.writeFileSync(gaPath, runGit(`git show origin/${remote.branch}:.gitattributes`, dir));
593+ } catch { /* 仓内无 .gitattributes → 无 lfs 声明,不可能存在指针件 */ }
594+ }
595+ }
596+ 
462 const locs = locateSessionLocations(dir, remote, sid);597 const locs = locateSessionLocations(dir, remote, sid);
463 if (locs.length === 0) throw new Error(`Session folder not found in cannbay2: "${sid}"`);598 if (locs.length === 0) throw new Error(`Session folder not found in cannbay2: "${sid}"`);
464 if (locs.length > 1) throw new Error(`Session "${sid}" exists in multiple locations (${locs.map(l => locationPath(l, sid)).join(', ')}): 数据仓布局损坏,请手工修复`);599 if (locs.length > 1) throw new Error(`Session "${sid}" exists in multiple locations (${locs.map(l => locationPath(l, sid)).join(', ')}): 数据仓布局损坏,请手工修复`);
@@ -479,21 +614,45 @@ export function materializeSession(sid: string, remote: Cannbay2Remote = default
479 const matDir = path.join(dir, 'materialized', sid);614 const matDir = path.join(dir, 'materialized', sid);
480 fs.rmSync(matDir, { recursive: true, force: true });615 fs.rmSync(matDir, { recursive: true, force: true });
481 let mainJsonl: string | null = null;616 let mainJsonl: string | null = null;
482- // 注:cat-file 触发逐对象懒取(每次一次 HTTPS 协商)。实测批量 fetch 无收益617+ // sessions/<sid>/<sid>.jsonl[.gz]|<sid>.meta.json → materialized/<sid>.jsonl[.gz]|.meta.json
483- // (1MB 会话 1.6-1.7s vs 逐取 1.1-1.5s,成本均在协商固定开销),保持简单。618+ // sessions/<sid>/subagents/* → materialized/<sid>/subagents/*
484- for (const e of entries) {619+ // .jsonl.gz 是压缩出仓形态(治理后 gzip,proxy/COMPRESSION-DESIGN.md);
485- const rel = e.repoPath.slice(prefix.length);620+ // 旧明文仓件照旧物化为 .jsonl —— 双形态并存,parseJsonlLines 透明解压。
486- // sessions/<sid>/<sid>.jsonl[.gz]|<sid>.meta.json → materialized/<sid>.jsonl[.gz]|.meta.json621+ const targetRelOf = (rel: string): string =>
487- // sessions/<sid>/subagents/* → materialized/<sid>/subagents/*622+ rel === `${sid}.jsonl` || rel === `${sid}.jsonl.gz` || rel === `${sid}.meta.json` ? rel : `${sid}/${rel}`;
488- // .jsonl.gz 是压缩出仓形态(治理后 gzip,proxy/COMPRESSION-DESIGN.md);623+ 
489- // 旧明文仓件照旧物化为 .jsonl —— 双形态并存,parseJsonlLines 透明解压。624+ if (remote.lfs) {
490- const targetRel = rel === `${sid}.jsonl` || rel === `${sid}.jsonl.gz` || rel === `${sid}.meta.json`625+ // LFS:指针 blob 实体在 LFS 存储,--filters smudge 逐文件拉取(不能批量)。
491- ? rel626+ // repoPath 来自 ls-tree -z 原始输出(除 NUL 外任意字节都可能出现),必须走
492- : `${sid}/${rel}`;627+ // spawnSync argv 形式——内插进 shell 字符串会被恶意文件名(如 `sub"; cmd;
493- const target = path.join(dir, 'materialized', targetRel);628+ // ".jsonl.gz`)注入任意命令;oid 是十六进制无此风险
494- fs.mkdirSync(path.dirname(target), { recursive: true });629+ for (const e of entries) {
495- fs.writeFileSync(target, runGit(`git cat-file -p ${e.oid}`, dir));630+ const rel = targetRelOf(e.repoPath.slice(prefix.length));
496- if (targetRel === `${sid}.jsonl` || targetRel === `${sid}.jsonl.gz`) mainJsonl = target;631+ const target = path.join(dir, 'materialized', rel);
632+ fs.mkdirSync(path.dirname(target), { recursive: true });
633+ const cat = spawnSync('git', ['cat-file', '--filters', `--path=${e.repoPath}`, e.oid],
634+ { cwd: dir, stdio: 'pipe', timeout: 300_000, maxBuffer: 256 * 1024 * 1024 });
635+ if (cat.error || cat.status !== 0) {
636+ throw new Error(`git cat-file --filters failed for "${e.repoPath}" (${e.oid}): ${cat.error?.message ?? cat.stderr?.toString()}`);
637+ }
638+ fs.writeFileSync(target, cat.stdout);
639+ if (rel === `${sid}.jsonl` || rel === `${sid}.jsonl.gz`) mainJsonl = target;
640+ }
641+ } else {
642+ // 批量物化:缺失 blob 先批量预取(一轮协商拉齐,替代逐文件懒取——实测
643+ // 167 缺失 ~80-95s 懒取 vs 7.2s 批量),再单进程分批写盘
644+ const local = localObjectOids(dir);
645+ const missing = entries.filter(e => !local.has(e.oid)).map(e => e.oid);
646+ if (missing.length > 0) prefetchBlobs(dir, missing);
647+ const planned: Array<{ oid: string; target: string }> = [];
648+ for (const e of entries) {
649+ const rel = targetRelOf(e.repoPath.slice(prefix.length));
650+ const target = path.join(dir, 'materialized', rel);
651+ fs.mkdirSync(path.dirname(target), { recursive: true });
652+ planned.push({ oid: e.oid, target });
653+ if (rel === `${sid}.jsonl` || rel === `${sid}.jsonl.gz`) mainJsonl = target;
654+ }
655+ materializeBlobsByBatch(dir, planned);
497 }656 }
498 if (!mainJsonl) throw new Error(`Main jsonl missing for session "${sid}"`);657 if (!mainJsonl) throw new Error(`Main jsonl missing for session "${sid}"`);
499 return mainJsonl;658 return mainJsonl;
@@ -520,6 +679,52 @@ export interface UploadResult {
520 gitattributesFixed: boolean;679 gitattributesFixed: boolean;
521}680}
522 681 
682+// ── LFS 模式(opt-in:remote.lfs,config.lfs > env CANNBAY2_LFS)─────────
683+ 
684+export function hasGitLfs(): boolean {
685+ try {
686+ const r = spawnSync('git', ['lfs', 'version'], { stdio: 'pipe', timeout: 10_000 });
687+ return r.status === 0;
688+ } catch {
689+ return false;
690+ }
691+}
692+ 
693+// git-lfs pre-push 钩子(与 `git lfs install` 写入的同款):push 时上传
694+// LFS 对象。手工写入而非调 `git lfs install`——后者会顺带改全局 gitconfig,
695+// 库代码不该碰用户全局配置。
696+const LFS_PRE_PUSH_HOOK = '#!/bin/sh\ncommand -v git-lfs >/dev/null 2>&1 || { echo "git-lfs not installed"; exit 2; }\ngit lfs pre-push "$@"\n';
697+ 
698+/** 镜像内 LFS 就绪:fail-fast git-lfs 在机检查 + 仓级 filter 配置 + pre-push
699+ * 钩子。幂等;不触碰全局配置(仓级配置优先级高于全局,双保险)。 */
700+export function ensureLfsReady(dir: string): void {
701+ if (!hasGitLfs()) {
702+ throw new Error('LFS 模式需要 git-lfs(`git lfs version` 不可用):请安装 git-lfs 并确保其在 PATH 中(https://git-lfs.github.com),或在设置中关闭 LFS 走普通 git 存储');
703+ }
704+ runGit('git config filter.lfs.process "git-lfs filter-process"', dir, 10_000, { raw: true });
705+ runGit('git config filter.lfs.smudge "git-lfs smudge -- %f"', dir, 10_000, { raw: true });
706+ runGit('git config filter.lfs.clean "git-lfs clean -- %f"', dir, 10_000, { raw: true });
707+ runGit('git config filter.lfs.required true', dir, 10_000, { raw: true });
708+ const hook = path.join(dir, '.git', 'hooks', 'pre-push');
709+ if (!fs.existsSync(hook)) {
710+ fs.mkdirSync(path.dirname(hook), { recursive: true });
711+ fs.writeFileSync(hook, LFS_PRE_PUSH_HOOK, { mode: 0o755 });
712+ }
713+}
714+ 
715+// LFS 模式下确保 .gitattributes 声明 *.gz 走 LFS(缺行补一行,与默认模式
716+// 的"剔除 lfs 行"互为镜像操作;模板仓自带该行则零改动)
717+function ensureLfsGitattributes(dir: string): void {
718+ const gaPath = path.join(dir, '.gitattributes');
719+ const want = '*.gz filter=lfs diff=lfs merge=lfs -text';
720+ let cur = '';
721+ try { cur = fs.readFileSync(gaPath, 'utf8'); } catch { /* 无文件 */ }
722+ if (!/^\*\.gz\s+filter=lfs/m.test(cur)) {
723+ fs.writeFileSync(gaPath, cur ? cur.replace(/\n*$/, '\n') + want + '\n' : `${want}\n`);
724+ runGit('git add .gitattributes', dir);
725+ }
726+}
727+ 
523/**728/**
524 * 把 stagingDir/sessions/<sid>/ 提交推送到远端 main。729 * 把 stagingDir/sessions/<sid>/ 提交推送到远端 main。
525 * - 目标位置:category + baseline → <category>/baseline/<sid>/(范式基准);730 * - 目标位置:category + baseline → <category>/baseline/<sid>/(范式基准);
@@ -547,6 +752,8 @@ export async function uploadFolder(stagingDir: string, sid: string, commitMessag
547 const targetRoot = category ?? remote.rootDir;752 const targetRoot = category ?? remote.rootDir;
548 const dir = ensureMirror(remote);753 const dir = ensureMirror(remote);
549 if (!revParseMain(dir, remote)) throw new Error('cannbay2 mirror has no main branch');754 if (!revParseMain(dir, remote)) throw new Error('cannbay2 mirror has no main branch');
755+ // LFS 模式:镜像内就绪(git-lfs 检查 + filter 配置 + pre-push 钩子)
756+ if (remote.lfs) ensureLfsReady(dir);
550 757 
551 // 本会话既有位置(除目标外全删:换范式 / 普通↔baseline 迁移)758 // 本会话既有位置(除目标外全删:换范式 / 普通↔baseline 迁移)
552 const staleLocs = locateSessionLocations(dir, remote, sid)759 const staleLocs = locateSessionLocations(dir, remote, sid)
@@ -587,7 +794,10 @@ export async function uploadFolder(stagingDir: string, sid: string, commitMessag
587 794 
588 let gitattributesFixed = false;795 let gitattributesFixed = false;
589 const gaPath = path.join(dir, '.gitattributes');796 const gaPath = path.join(dir, '.gitattributes');
590- if (fs.existsSync(gaPath)) {797+ if (remote.lfs) {
798+ // LFS 模式:反向操作——确保 *.gz 声明走 LFS(缺则补),绝不剔除
799+ ensureLfsGitattributes(dir);
800+ } else if (fs.existsSync(gaPath)) {
591 const ga = fs.readFileSync(gaPath, 'utf8');801 const ga = fs.readFileSync(gaPath, 'utf8');
592 const fixed = ga.split('\n').filter(l => !l.includes('filter=lfs')).join('\n');802 const fixed = ga.split('\n').filter(l => !l.includes('filter=lfs')).join('\n');
593 if (fixed !== ga) {803 if (fixed !== ga) {
@@ -598,7 +808,9 @@ export async function uploadFolder(stagingDir: string, sid: string, commitMessag
598 }808 }
599 809 
600 const addPaths = [targetRel, ...staleLocs.map(l => locationPath(l, sid)), ...swapPaths, ...(baseline ? [`${targetRoot}/baseline`] : [])];810 const addPaths = [targetRel, ...staleLocs.map(l => locationPath(l, sid)), ...swapPaths, ...(baseline ? [`${targetRoot}/baseline`] : [])];
601- runGit(`git add ${addPaths.join(' ')}`, dir);811+ // LFS 模式 add 不中和:clean 过滤器要把 .gz 转成 LFS 指针(对指针文本
812+ // 透传,baseline 搬移的 re-add 不重传)
813+ runGit(`git add ${addPaths.join(' ')}`, dir, 120_000, remote.lfs ? { raw: true } : undefined);
602 const msgFile = path.join(dir, '_commit_msg.txt');814 const msgFile = path.join(dir, '_commit_msg.txt');
603 fs.writeFileSync(msgFile, commitMessage);815 fs.writeFileSync(msgFile, commitMessage);
604 let unchanged = false;816 let unchanged = false;
@@ -617,9 +829,7 @@ export async function uploadFolder(stagingDir: string, sid: string, commitMessag
617 try { fs.unlinkSync(msgFile); } catch { /* ignore */ }829 try { fs.unlinkSync(msgFile); } catch { /* ignore */ }
618 830 
619 if (!unchanged) {831 if (!unchanged) {
620- // --no-verify:镜像克隆可能带 lfs pre-push 钩子(git lfs install 写入),832+ const push = () => { pushMirror(dir, remote); };
621- // 会因孤儿指针/远端 LFS 未启用而拒绝 push——与 runGit 的 filter 中和配套
622- const push = () => { runGit(`git push --no-verify "${remote.pushUrl}" ${remote.branch}`, dir, 300_000); };
623 try {833 try {
624 push();834 push();
625 } catch {835 } catch {
@@ -631,3 +841,44 @@ export async function uploadFolder(stagingDir: string, sid: string, commitMessag
631 return { folder: `${targetRel}/`, unchanged, gitattributesFixed };841 return { folder: `${targetRel}/`, unchanged, gitattributesFixed };
632 });842 });
633}843}
844+ 
845+/**
846+ * 401 挑战-应答式 push:pushUrl 一律无凭据,凭据经一次性 credential helper
847+ * 供给——atomgit 网关已拒"请求直带密码"的内嵌 URL(proactive Basic 直接
848+ * CH.00905401 拒绝;同凭据经 401 挑战后重试可通过,2026-09-14 实测)。
849+ * helper 脚本只落 0700 临时目录、用后即删,凭据不进命令行(execSync 失败
850+ * 消息不含凭据)。`-c credential.helper=` 先清空继承链(全局 store 等),
851+ * 保证显式凭据确定性生效。pushAuth=null(本地 bare / 匿名推)直推。
852+ * LFS 模式:不用 --no-verify(pre-push 钩子 = LFS 对象上传通道,跳过即
853+ * 产生孤儿指针);helper 的 -c 配置经 GIT_CONFIG_PARAMETERS 传给钩子,
854+ * git-lfs 的 batch API 鉴权与 git push 同源。
855+ */
856+export function pushMirror(dir: string, remote: Cannbay2Remote): void {
857+ const verify = remote.lfs ? '' : ' --no-verify';
858+ const raw = remote.lfs ? { raw: true as const } : undefined;
859+ if (!remote.pushAuth) {
860+ runGit(`git push${verify} "${remote.pushUrl}" ${remote.branch}`, dir, 300_000, raw);
861+ return;
862+ }
863+ const { username, password } = remote.pushAuth;
864+ if (/[\r\n]/.test(username) || /[\r\n]/.test(password)) {
865+ throw new Error('push 凭据含换行符(credential helper 协议非法)');
866+ }
867+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'insight-cannbay2-auth-'));
868+ try {
869+ const helper = path.join(tmp, 'helper.sh');
870+ fs.writeFileSync(
871+ helper,
872+ `#!/bin/sh\ncat <<'INSIGHTEOF'\nusername=${username}\npassword=${password}\nINSIGHTEOF\n`,
873+ { mode: 0o700 },
874+ );
875+ runGit(
876+ `git -c credential.helper= -c credential.helper="${helper}" push${verify} "${remote.pushUrl}" ${remote.branch}`,
877+ dir,
878+ 300_000,
879+ raw,
880+ );
881+ } finally {
882+ fs.rmSync(tmp, { recursive: true, force: true });
883+ }
884+}
@@ -17,14 +17,43 @@ import type { CannbayRemoteInput } from '@/lib/cannbay-config';
17export const CANNBAY2_PULL_URL = process.env.CANNBAY2_REMOTE_URL17export const CANNBAY2_PULL_URL = process.env.CANNBAY2_REMOTE_URL
18 ?? 'https://atomgit.com/duanbingzhe/cannbay3.git';18 ?? 'https://atomgit.com/duanbingzhe/cannbay3.git';
19// 密文常量(与 v1 upload-session/route.ts 同方式),解码 = https://<user>:<token>@atomgit.com/…19// 密文常量(与 v1 upload-session/route.ts 同方式),解码 = https://<user>:<token>@atomgit.com/…
20+// 它是 push 凭据源(auth 提取),不再内嵌进 push URL:atomgit 已拒"请求直带
21+// 密码"式鉴权,凭据只经 credential helper 的 401 挑战-应答通道供给(见
22+// mirror.ts pushMirror)。
20const CANNBAY2_PUSH_URL_ENCODED = 'aHR0cHM6Ly9kdWFuYmluZ3poZTpvU1I1aTF3WXZ5dlZpUmdMbXdna3NDeG5AYXRvbWdpdC5jb20vZHVhbmJpbmd6aGUvY2FubmJheTMuZ2l0';23const CANNBAY2_PUSH_URL_ENCODED = 'aHR0cHM6Ly9kdWFuYmluZ3poZTpvU1I1aTF3WXZ5dlZpUmdMbXdna3NDeG5AYXRvbWdpdC5jb20vZHVhbmJpbmd6aGUvY2FubmJheTMuZ2l0';
21export const CANNBAY2_PUSH_URL = process.env.CANNBAY2_PUSH_URL24export const CANNBAY2_PUSH_URL = process.env.CANNBAY2_PUSH_URL
22 ?? Buffer.from(CANNBAY2_PUSH_URL_ENCODED, 'base64').toString();25 ?? Buffer.from(CANNBAY2_PUSH_URL_ENCODED, 'base64').toString();
23export const CANNBAY2_BRANCH = process.env.CANNBAY2_BRANCH ?? 'main';26export const CANNBAY2_BRANCH = process.env.CANNBAY2_BRANCH ?? 'main';
24 27 
28+// LFS 模式(惰性读):.jsonl.gz 以 LFS 指针出仓,实体进 LFS 存储——数据量
29+// 不再计入 git 仓配额。请求体 config.lfs > env CANNBAY2_LFS > 默认关。
30+function envLfs(): boolean {
31+ const v = process.env.CANNBAY2_LFS;
32+ return v === '1' || v === 'true' || v === 'on';
33+}
34+ 
35+export interface PushAuth {
36+ username: string;
37+ password: string;
38+}
39+ 
40+/** https URL 内嵌凭据拆分:`scheme://user:pass@rest` → 无凭据 URL + auth 对;
41+ * 无凭据(本地路径 / 匿名 URL)原样返回、auth=null。 */
42+export function parseUrlCreds(url: string): { credless: string; auth: PushAuth | null } {
43+ const m = url.match(/^(https?:\/\/)([^/@:\s]+):([^@\s]+)@/i);
44+ if (!m) return { credless: url, auth: null };
45+ return {
46+ credless: url.slice(0, m[1].length) + url.slice(m[0].length),
47+ auth: { username: decodeURIComponent(m[2]), password: decodeURIComponent(m[3]) },
48+ };
49+}
50+ 
25export interface Cannbay2Remote {51export interface Cannbay2Remote {
26 pullUrl: string;52 pullUrl: string;
53+ /** 无凭据 push 地址:凭据走 credential helper(pushAuth),绝不内嵌 URL */
27 pushUrl: string;54 pushUrl: string;
55+ /** push 凭据(内置共享令牌 / 自定义地址的表单凭据);null = 匿名推 */
56+ pushAuth: PushAuth | null;
28 branch: string;57 branch: string;
29 rootDir: string;58 rootDir: string;
30 cacheDir: string;59 cacheDir: string;
@@ -34,6 +63,8 @@ export interface Cannbay2Remote {
34 * 新增范式 = 在数据仓新建顶层目录,insight 零范式知识。63 * 新增范式 = 在数据仓新建顶层目录,insight 零范式知识。
35 */64 */
36 paradigmRoots: boolean;65 paradigmRoots: boolean;
66+ /** LFS 模式:.jsonl.gz 走 LFS 指针(config.lfs > env CANNBAY2_LFS;缺省 = 既有免疫行为) */
67+ lfs?: boolean;
37}68}
38 69 
39const FORBIDDEN_ADDRESS_CHARS = /["'`$;&|\n\r]/;70const FORBIDDEN_ADDRESS_CHARS = /["'`$;&|\n\r]/;
@@ -151,15 +182,20 @@ export function resolveCannbay2Remote(input?: CannbayRemoteInput): Cannbay2Remot
151 // 在设置页配置的数据之湖凭据只对自定义地址生效。否则 embedCredentials 会182 // 在设置页配置的数据之湖凭据只对自定义地址生效。否则 embedCredentials 会
152 // 剥掉内置凭据换成用户身份 → atomgit CH.00905403 "not allowed to push":183 // 剥掉内置凭据换成用户身份 → atomgit CH.00905403 "not allowed to push":
153 // 客户令牌鉴权通过但对共享仓无写权限(拉取/列表仍带用户凭据,私有数据湖184 // 客户令牌鉴权通过但对共享仓无写权限(拉取/列表仍带用户凭据,私有数据湖
154- // 场景不受影响)。185+ // 场景不受影响)。凭据经 parseUrlCreds 提取走 credential helper,pushUrl
155- const pushUrl = hasAddress ? embedCredentials(credlessPull, input) : CANNBAY2_PUSH_URL;186+ // 本体无凭据(atomgit 拒请求直带密码的内嵌 URL)。
187+ const { credless: pushUrl, auth: pushAuth } = parseUrlCreds(
188+ hasAddress ? embedCredentials(credlessPull, input) : CANNBAY2_PUSH_URL,
189+ );
156 return {190 return {
157 pullUrl: embedCredentials(credlessPull, input),191 pullUrl: embedCredentials(credlessPull, input),
158 pushUrl,192 pushUrl,
193+ pushAuth,
159 branch,194 branch,
160 rootDir: normalizeDirectory(input?.directory),195 rootDir: normalizeDirectory(input?.directory),
161 cacheDir,196 cacheDir,
162 paradigmRoots: !hasAddress,197 paradigmRoots: !hasAddress,
198+ lfs: input?.lfs ?? envLfs(),
163 };199 };
164}200}
165 201 
@@ -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// 单会话基线:基线 = 一个样例会话(同类任务各设一个),不是语料分布。9// 单会话基线:基线 = 一个样例会话(同类任务各设一个),不是语料分布。
10-// 新会话与基线会话逐指标比比值——与 CANNBay「范式 baseline 会话」(位置即身份)10+// 新会话与基线会话逐指标比比值——与数据仓「范式 baseline 会话」(位置即身份)
11// 同构:一个标杆,不做统计。存 ~/.cannbot-insight/baselines/<taskType>.json。11// 同构:一个标杆,不做统计。存 ~/.cannbot-insight/baselines/<taskType>.json。
12 12 
13import { promises as fs } from 'node:fs';13import { promises as fs } from 'node:fs';
@@ -6,5 +6,5 @@
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-export const VERSION = "0.2.28"9+export const VERSION = "0.2.29"
10export const VERSION_DISPLAY = `v${VERSION}`10export const VERSION_DISPLAY = `v${VERSION}`
@@ -95,8 +95,13 @@ if [ -f "$PROXY_INSTALL" ]; then
95 echo "[setup] Setting up proxy (cpx)..."95 echo "[setup] Setting up proxy (cpx)..."
96 PROXY_INSTALL_ARGS=()96 PROXY_INSTALL_ARGS=()
97 if [ "$UPDATE" = true ]; then PROXY_INSTALL_ARGS+=("-u"); fi97 if [ "$UPDATE" = true ]; then PROXY_INSTALL_ARGS+=("-u"); fi
98- if ! bash "$PROXY_INSTALL" "${PROXY_INSTALL_ARGS[@]}"; then98+ mkdir -p "$SCRIPT_DIR/tmp"
99- echo "[setup] proxy install failed (non-fatal)"99+ PROXY_LOG="$SCRIPT_DIR/tmp/cpx-install.log"
100+ if bash "$PROXY_INSTALL" "${PROXY_INSTALL_ARGS[@]}" > "$PROXY_LOG" 2>&1; then
101+ echo "[setup] cpx ready ✓ (log: tmp/cpx-install.log)"
102+ else
103+ echo "[setup] proxy install failed (non-fatal):"
104+ tail -n 20 "$PROXY_LOG"
100 fi105 fi
101fi106fi
102 107 
@@ -303,11 +308,14 @@ echo "[start] Launching CANNBot-Insight on port $PORT..."
303# "某个 turn 首次点击数秒"的主因即此;生产模式启动时一次性构建,请求零编译。308# "某个 turn 首次点击数秒"的主因即此;生产模式启动时一次性构建,请求零编译。
304# 构建产物复用:源码(src/prisma/public/配置)没有比 .next/BUILD_ID 更新的309# 构建产物复用:源码(src/prisma/public/配置)没有比 .next/BUILD_ID 更新的
305# 文件即跳过重建——改代码后自动重建,无需记 -f;-f 仍可强制。310# 文件即跳过重建——改代码后自动重建,无需记 -f;-f 仍可强制。
311+# public/export-view.js 例外:它是 build:export-view 的产物(每次启动重写),
312+# 且 public/ 由 next start 运行时直读、不进 next build 产物——不排除则每次
313+# 启动都误判"源码已变"触发无谓全量重建。
306NEXT_CMD="dev"314NEXT_CMD="dev"
307if [ "$PROD" = true ] && [ "$CLI" != true ]; then315if [ "$PROD" = true ] && [ "$CLI" != true ]; then
308 NEED_BUILD=true316 NEED_BUILD=true
309 if [ "$FRESH" != true ] && [ -f ".next/BUILD_ID" ]; then317 if [ "$FRESH" != true ] && [ -f ".next/BUILD_ID" ]; then
310- STALE=$(find src prisma public package.json next.config.ts tsconfig.json -newer .next/BUILD_ID -print -quit 2>/dev/null || true)318+ STALE=$(find src prisma public package.json next.config.ts tsconfig.json -newer .next/BUILD_ID ! -path "public/export-view.js" -print -quit 2>/dev/null || true)
311 if [ -z "$STALE" ]; then319 if [ -z "$STALE" ]; then
312 NEED_BUILD=false320 NEED_BUILD=false
313 echo "[start] Production build cached (.next/BUILD_ID up to date) — skip"321 echo "[start] Production build cached (.next/BUILD_ID up to date) — skip"
@@ -317,7 +325,15 @@ if [ "$PROD" = true ] && [ "$CLI" != true ]; then
317 fi325 fi
318 if [ "$NEED_BUILD" = true ]; then326 if [ "$NEED_BUILD" = true ]; then
319 echo "[start] Production build (next build)..."327 echo "[start] Production build (next build)..."
320- npm run build || { echo "[start] ERROR: build failed" >&2; exit 1; }328+ mkdir -p "$SCRIPT_DIR/tmp"
329+ BUILD_LOG="$SCRIPT_DIR/tmp/next-build.log"
330+ if ! npm run build > "$BUILD_LOG" 2>&1; then
331+ echo "[start] ERROR: build failed — tail of $BUILD_LOG:" >&2
332+ tail -n 40 "$BUILD_LOG" >&2
333+ exit 1
334+ fi
335+ COMPILED=$(grep -o 'Compiled successfully in [0-9.]*m\?s' "$BUILD_LOG" | head -1)
336+ echo "[start] Build OK${COMPILED:+ — $COMPILED} (full log: tmp/next-build.log)"
321 fi337 fi
322 NEXT_CMD="start"338 NEXT_CMD="start"
323fi339fi
@@ -21,13 +21,16 @@ import { createRoot, type Root } from 'react-dom/client'
21import { act } from 'react'21import { act } from 'react'
22import fs from 'node:fs'22import fs from 'node:fs'
23import path from 'node:path'23import path from 'node:path'
24+import { BAY_NAME } from '@/lib/branding'
24 25 
25vi.mock('next/navigation', () => ({26vi.mock('next/navigation', () => ({
26 useSearchParams: () => new URLSearchParams('framework=opencode'),27 useSearchParams: () => new URLSearchParams('framework=opencode'),
27 useRouter: () => ({ push: () => {}, replace: () => {}, back: () => {}, refresh: () => {} }),28 useRouter: () => ({ push: () => {}, replace: () => {}, back: () => {}, refresh: () => {} }),
28}))29}))
29 30 
30-const DATA = path.resolve(__dirname, 'data', 'session-turn-accumulate')31+// fixture 随测试目录迁移落在 tests/ut/data/(上游变基适配时本文件移入
32+// tests/e2e/ 而数据未跟迁,路径漏适配曾致 readJson ENOENT)
33+const DATA = path.resolve(__dirname, '..', 'ut', 'data', 'session-turn-accumulate')
31const TASK_ID = 'ses_066d0d8aaffegJAFUjUGyxKm8z'34const TASK_ID = 'ses_066d0d8aaffegJAFUjUGyxKm8z'
32 35 
33function readJson(name: string): unknown {36function readJson(name: string): unknown {
@@ -113,9 +116,9 @@ describe('会话页 CANNBay 上传入口(共享弹窗)', () => {
113 expect(document.body.textContent).toContain('Session:')116 expect(document.body.textContent).toContain('Session:')
114 expect(findButtonByText('Turns'), '页面加载失败').toBeTruthy()117 expect(findButtonByText('Turns'), '页面加载失败').toBeTruthy()
115 118 
116- // ① 标题栏按钮存在(Export HTML 之后)119+ // ① 标题栏按钮存在(Export HTML 之后);title 跟随 BAY_NAME 品牌
117 const htmlBtn = document.querySelector('button[title="Export HTML"]')120 const htmlBtn = document.querySelector('button[title="Export HTML"]')
118- const uploadBtn = document.querySelector('button[title="Upload to CANNBay"]')121+ const uploadBtn = document.querySelector(`button[title="Upload to ${BAY_NAME}"]`)
119 expect(htmlBtn, 'Export HTML 按钮未找到').toBeTruthy()122 expect(htmlBtn, 'Export HTML 按钮未找到').toBeTruthy()
120 expect(uploadBtn, '会话页上传按钮未找到').toBeTruthy()123 expect(uploadBtn, '会话页上传按钮未找到').toBeTruthy()
121 expect(uploadBtn!.compareDocumentPosition(htmlBtn!) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy()124 expect(uploadBtn!.compareDocumentPosition(htmlBtn!) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy()
@@ -124,7 +127,7 @@ describe('会话页 CANNBay 上传入口(共享弹窗)', () => {
124 // ([data-slot="dialog-content"])——keep-mounted 隐藏 tab 里有同名元素127 // ([data-slot="dialog-content"])——keep-mounted 隐藏 tab 里有同名元素
125 await act(async () => { click(uploadBtn!) })128 await act(async () => { click(uploadBtn!) })
126 await flush()129 await flush()
127- expect(document.body.textContent).toContain('上传到 CANNBay')130+ expect(document.body.textContent).toContain(`上传到 ${BAY_NAME}`)
128 const dlg = document.querySelector('[data-slot="dialog-content"]') as HTMLElement131 const dlg = document.querySelector('[data-slot="dialog-content"]') as HTMLElement
129 expect(dlg, '上传弹窗未渲染').toBeTruthy()132 expect(dlg, '上传弹窗未渲染').toBeTruthy()
130 for (const label of ['范式目录', '提交人', '内容描述', '算子生成结果']) {133 for (const label of ['范式目录', '提交人', '内容描述', '算子生成结果']) {
@@ -57,7 +57,10 @@ fs.writeFileSync(path.join(SEED, 'others', POISON_SID, `${POISON_SID}.jsonl.gz`)
57fs.writeFileSync(path.join(SEED, 'others', POISON_SID, 'subagents', 'sub-poison.jsonl.gz'), POINTER_BLOB);57fs.writeFileSync(path.join(SEED, 'others', POISON_SID, 'subagents', 'sub-poison.jsonl.gz'), POINTER_BLOB);
58git('git add .', SEED);58git('git add .', SEED);
59git('git -c user.name=seed -c user.email=seed@t commit -q -m init', SEED);59git('git -c user.name=seed -c user.email=seed@t commit -q -m init', SEED);
60-git('git push -q origin main', SEED);60+// --no-verify:装了 git-lfs 的机器上新克隆自带 lfs pre-push 钩子,seed 推
61+// 的是故意放的孤儿指针(LFS 对象 1111… 本就不存在),钩子必拒——跳过钩子
62+// 保证 seed 与机器 git-lfs 状态无关(生产代码的免疫另有三层防线,不靠这里)
63+git('git push --no-verify -q origin main', SEED);
61 64 
62process.env.CANNBAY2_REMOTE_URL = BARE;65process.env.CANNBAY2_REMOTE_URL = BARE;
63process.env.CANNBAY2_PUSH_URL = BARE;66process.env.CANNBAY2_PUSH_URL = BARE;
@@ -0,0 +1,179 @@
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+// cannbay2 LFS 模式 IT(与 cpx tests/cannbay-lfs.test.ts 对位):remote.lfs
10+// 显式构造(不走 env,隔离干净),file:// 本地 bare + git-lfs standalone
11+// 传输。镜像层闭环验证:
12+// ① uploadFolder:.jsonl.gz 出仓为 LFS 指针、实体落 bare.git/lfs/objects、
13+// .gitattributes 保留 filter=lfs 行(缺行自动补)、meta.json 普通 blob
14+// ② materializeSession:指针经 cat-file --filters(smudge 按需取实体)
15+// 恢复真实 gzip 字节——LFS 仓导入闭环
16+// ③ listSessions:meta.json 是普通 blob,type 列照常可读
17+// ④ 幂等 unchanged(指针级)
18+// ⑤ 注入面回归锁:repoPath 含 shell 元字符(引号/分号/空格)的文件名
19+// (ls-tree -z 原始输出,除 NUL 外任意字节都可能)物化不得执行注入
20+// 命令——cat-file 必须走 spawnSync argv 形式,不得内插 shell 字符串
21+// ⑥ 工作树 .gitattributes 过期回归锁:默认模式上传剔除过 lfs 行的副本
22+// 留在镜像工作树,LFS 导入前必须按"缺声明"判定刷新(不是仅看文件
23+// 存在)——否则 --filters 不做 smudge,指针文本被当 gzip 落盘
24+// 客户机无 git-lfs 时整组跳过(LFS 模式的硬前置)。
25+ 
26+import { describe, it, expect } from 'vitest';
27+import { execSync } from 'node:child_process';
28+import { createHash } from 'node:crypto';
29+import fs from 'node:fs';
30+import os from 'node:os';
31+import path from 'node:path';
32+import zlib from 'node:zlib';
33+import { hasGitLfs } from '../../src/lib/cannbay2/mirror';
34+import type { Cannbay2Remote } from '../../src/lib/cannbay2/remote';
35+ 
36+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'cannbay2-lfsmode-it-'));
37+const BARE = path.join(TMP, 'remote.git');
38+const BARE_URL = `file://${BARE}`;
39+const SEED = path.join(TMP, 'seed');
40+const STAGING = path.join(TMP, 'staging');
41+const CACHE = path.join(TMP, 'cache');
42+ 
43+const SID = 'ses_lfsmode01ffeetestAA';
44+// 注入探针文件名:内插进双引号 shell 字符串时 `"; touch <PWN_NAME>; "` 会截断
45+// 命令并执行 touch(旧实现 execSync 的 cwd=镜像目录,文件落在镜像根)——
46+// argv 形式下只是普通文件名。文件名不能带 /(会被当目录分隔符),哨兵用相对名
47+const PWN_NAME = 'pwned-by-injection';
48+const EVIL_NAME = `evil"; touch ${PWN_NAME}; ".jsonl.gz`;
49+const EVIL_GZ = zlib.gzipSync('{"type":"user","message":{"role":"user","content":"sub"}}\n');
50+ 
51+function git(cmd: string, cwd: string): string {
52+ return execSync(cmd, { cwd, stdio: 'pipe', timeout: 60_000 }).toString();
53+}
54+ 
55+function buildRemote(): Cannbay2Remote {
56+ return {
57+ pullUrl: BARE_URL,
58+ pushUrl: BARE_URL,
59+ pushAuth: null,
60+ branch: 'main',
61+ rootDir: 'sessions',
62+ cacheDir: CACHE,
63+ paradigmRoots: true,
64+ lfs: true,
65+ };
66+}
67+ 
68+// ── seed:带 *.gz lfs 声明的 .gitattributes + others/ 范式目录 ──
69+execSync(`git init --bare -b main "${BARE}"`, { stdio: 'pipe' });
70+execSync(`git clone --quiet "${BARE_URL}" "${SEED}"`, { stdio: 'pipe' });
71+fs.writeFileSync(path.join(SEED, '.gitattributes'), '*.gz filter=lfs diff=lfs merge=lfs -text\n');
72+fs.mkdirSync(path.join(SEED, 'others'), { recursive: true });
73+fs.writeFileSync(path.join(SEED, 'others', '.gitkeep'), '');
74+git('git add .', SEED);
75+git('git -c user.name=seed -c user.email=seed@t commit -q -m init', SEED);
76+git('git push -q origin main', SEED);
77+ 
78+// ── staging:sessions/<sid>/<sid>.jsonl.gz + meta.json(镜像层接口约定) ──
79+const SESSION_LINES = [
80+ JSON.stringify({ type: 'user', message: { role: 'user', content: 'lfs mode fixture' }, timestamp: '2026-09-15T03:00:00.000Z' }),
81+ JSON.stringify({ type: 'assistant', message: { id: 'm1', role: 'assistant', content: [{ type: 'text', text: 'ok' }], model: 'test-model', usage: { input_tokens: 10, output_tokens: 2 } }, timestamp: '2026-09-15T03:00:05.000Z' }),
82+].join('\n') + '\n';
83+const MAIN_GZ = zlib.gzipSync(SESSION_LINES);
84+fs.mkdirSync(path.join(STAGING, 'sessions', SID), { recursive: true });
85+fs.writeFileSync(path.join(STAGING, 'sessions', SID, `${SID}.jsonl.gz`), MAIN_GZ);
86+fs.writeFileSync(path.join(STAGING, 'sessions', SID, `${SID}.meta.json`), JSON.stringify({
87+ x_cannbay: { schema: 'cc-session-meta', version: 1, data: { producer: 'cpx', framework: 'claude-code', ccVersion: '9.9.9', sid: SID } },
88+}));
89+// subagents/ 下放注入探针文件(随 ① 的 uploadFolder 出仓为 LFS 指针)
90+fs.mkdirSync(path.join(STAGING, 'sessions', SID, 'subagents'), { recursive: true });
91+fs.writeFileSync(path.join(STAGING, 'sessions', SID, 'subagents', EVIL_NAME), EVIL_GZ);
92+ 
93+const { uploadFolder, materializeSession, listSessions } = await import('../../src/lib/cannbay2/mirror');
94+ 
95+function blobBuffer(repoPath: string): Buffer {
96+ const oid = git(`git rev-parse main:${repoPath}`, BARE).trim();
97+ return execSync(`git cat-file -p ${oid}`, { cwd: BARE, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 256 * 1024 * 1024 }) as Buffer;
98+}
99+ 
100+function lfsObjectPath(oidHex: string): string {
101+ return path.join(BARE, 'lfs', 'objects', oidHex.slice(0, 2), oidHex.slice(2, 4), oidHex);
102+}
103+ 
104+const DESC = '提交人: 王五\n内容描述: lfs mode it';
105+ 
106+describe.skipIf(!hasGitLfs())('cannbay2 LFS 模式(file:// bare + standalone 传输)', () => {
107+ it('① uploadFolder:指针出仓 + 实体落 LFS 存储 + .gitattributes 保留', async () => {
108+ const r = await uploadFolder(STAGING, SID, DESC, buildRemote(), 'others');
109+ expect(r.folder).toBe(`others/${SID}/`);
110+ expect(r.gitattributesFixed).toBe(false);
111+ 
112+ const pointer = blobBuffer(`others/${SID}/${SID}.jsonl.gz`).toString('utf8');
113+ const oidHex = createHash('sha256').update(MAIN_GZ).digest('hex');
114+ expect(pointer.startsWith('version https://git-lfs.github.com/spec/v1')).toBe(true);
115+ expect(pointer).toContain(`oid sha256:${oidHex}`);
116+ expect(pointer).toContain(`size ${MAIN_GZ.length}`);
117+ expect(fs.existsSync(lfsObjectPath(oidHex))).toBe(true);
118+ expect(fs.readFileSync(lfsObjectPath(oidHex)).equals(MAIN_GZ)).toBe(true);
119+ 
120+ const meta = blobBuffer(`others/${SID}/${SID}.meta.json`).toString('utf8');
121+ expect(meta).toContain('"framework":"claude-code"');
122+ expect(meta).not.toContain('git-lfs');
123+ 
124+ expect(blobBuffer('.gitattributes').toString('utf8')).toContain('*.gz filter=lfs');
125+ });
126+ 
127+ it('② materializeSession:指针 → 真实 gzip 字节(--filters smudge)', () => {
128+ const mainJsonl = materializeSession(SID, buildRemote());
129+ expect(path.basename(mainJsonl)).toBe(`${SID}.jsonl.gz`);
130+ expect(fs.readFileSync(mainJsonl).equals(MAIN_GZ)).toBe(true);
131+ });
132+ 
133+ it('③ listSessions:meta.json 普通 blob,type 列照常', () => {
134+ const sessions = listSessions(buildRemote());
135+ const entry = sessions.find(s => s.sid === SID);
136+ expect(entry).toBeTruthy();
137+ expect(entry!.framework).toBe('claude-code');
138+ expect(entry!.ccVersion).toBe('9.9.9');
139+ expect(entry!.paradigm).toBe('others');
140+ });
141+ 
142+ it('④ 幂等:重复上传同内容 → 指针级 unchanged', async () => {
143+ const commitsBefore = git('git rev-list --count main', BARE).trim();
144+ const r = await uploadFolder(STAGING, SID, DESC, buildRemote(), 'others');
145+ expect(r.unchanged).toBe(true);
146+ expect(git('git rev-list --count main', BARE).trim()).toBe(commitsBefore);
147+ });
148+ 
149+ it('⑤ 注入面:repoPath 含 shell 元字符(引号/分号/空格)物化不执行注入命令', () => {
150+ const mainJsonl = materializeSession(SID, buildRemote());
151+ // 主件照常恢复(怪名文件在同树中,不拖垮整仓物化)
152+ expect(fs.readFileSync(mainJsonl).equals(MAIN_GZ)).toBe(true);
153+ // 探针文件字节级恢复(subagents/ 前缀重排到 <sid>/subagents/)
154+ const matSub = path.join(path.dirname(mainJsonl), SID, 'subagents', EVIL_NAME);
155+ expect(fs.existsSync(matSub)).toBe(true);
156+ expect(fs.readFileSync(matSub).equals(EVIL_GZ)).toBe(true);
157+ // 关键断言:注入命令未被执行——旧实现内插 shell 字符串,touch 的相对
158+ // 哨兵名会落在 execSync 的 cwd(镜像根 = materialized/<sid> 的上两级)
159+ const mirrorRoot = path.resolve(path.dirname(mainJsonl), '..', '..');
160+ expect(fs.existsSync(path.join(mirrorRoot, PWN_NAME))).toBe(false);
161+ });
162+ 
163+ it('⑥ 工作树 .gitattributes 过期(默认模式上传剔除过 lfs 行):导入前刷新,指针不被当 gzip 落盘', () => {
164+ // 复现序列:同一镜像默认模式 uploadFolder 把剔除 filter=lfs 的
165+ // .gitattributes 写回工作树(LFS 免疫操作)→ 远端(cpx 侧)切回 LFS 模式
166+ // 上传加回声明 → 本机 LFS 导入读到的还是过期副本
167+ const gaPath = path.join(CACHE, '.gitattributes');
168+ expect(fs.readFileSync(gaPath, 'utf8')).toContain('*.gz filter=lfs'); // 前置:当前副本带声明
169+ const stripped = fs.readFileSync(gaPath, 'utf8').split('\n').filter(l => !l.includes('filter=lfs')).join('\n');
170+ fs.writeFileSync(gaPath, stripped);
171+ expect(fs.readFileSync(gaPath, 'utf8')).not.toContain('filter=lfs'); // 前置:副本已过期
172+ 
173+ const mainJsonl = materializeSession(SID, buildRemote());
174+ // 工作树副本已从 origin/main 刷新(声明回归)
175+ expect(fs.readFileSync(gaPath, 'utf8')).toContain('*.gz filter=lfs');
176+ // 实体照常恢复——不是指针文本(指针 gunzip 必炸且字节不等)
177+ expect(fs.readFileSync(mainJsonl).equals(MAIN_GZ)).toBe(true);
178+ });
179+});
@@ -36,7 +36,7 @@ function git(cmd: string, cwd: string): void {
36}36}
37 37 
38function makeRemote(cacheDir: string): Cannbay2Remote {38function makeRemote(cacheDir: string): Cannbay2Remote {
39- return { pullUrl: BARE, pushUrl: BARE, branch: 'main', rootDir: 'sessions', cacheDir, paradigmRoots: true };39+ return { pullUrl: BARE, pushUrl: BARE, pushAuth: null, branch: 'main', rootDir: 'sessions', cacheDir, paradigmRoots: true };
40}40}
41 41 
42beforeAll(() => {42beforeAll(() => {
@@ -0,0 +1,184 @@
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+// cannbay2 读路径性能修复 IT:blob 批量预取 + 列表/目录合并读取。
10+// 背景(atomgit 实测):partial clone 懒取 = 每个缺失 blob 一次独立 HTTPS
11+// 协商(~1s/个)——列表 2817 个 meta 中 2370 缺失时 readSessionMetas 被
12+// 120s 超时截断、type 列残缺;物化 167 缺失 blob 逐个懒取 ~80-95s。修复 =
13+// 一轮 git fetch <oids> 批量拉齐(实测 1601 个 9.3s vs 懒取 ~28min)+
14+// listOverview 让导入对话框的范式下拉+会话表共享单轮 mirror fetch(原先
15+// dirs/list 两路各 fetch 一次)。覆盖:
16+// ① prefetchBlobs:真 partial clone 上批量拉齐缺失 blob(本地对象清单验证
17+// ——不用 cat-file -e,它自己会触发懒取造成假阳性);非法/远端不存在的
18+// oid 静默跳过不炸(回退懒取路径)
19+// ② listSessions:250 会话 meta(跨 FETCH_OID_CHUNK=200 分块)framework/
20+// producer/opGen 全量回填,提交人从 commit subject 解析
21+// ③ materializeSession:主 .jsonl.gz + 300 子代理(跨 128/批)逐字节一致
22+// (gzip 二进制安全:Buffer 直写不经 utf8 往返),布局重排正确
23+// ④ listOverview:sessions + topDirs 单轮 fetch 合并返回,topDirs 与
24+// listRemoteDirs('') 同源(含存量区 sessions/、含范式目录);自定义
25+// 单根远端 topDirs 恒 []
26+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
27+import { execSync } from 'node:child_process';
28+import fs from 'node:fs';
29+import path from 'node:path';
30+import os from 'node:os';
31+import zlib from 'node:zlib';
32+import { ensureMirror, listSessions, listOverview, listRemoteDirs, materializeSession, prefetchBlobs, localObjectOids } from '../../src/lib/cannbay2/mirror';
33+import type { Cannbay2Remote } from '../../src/lib/cannbay2/remote';
34+ 
35+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'cannbay2-prefetch-'));
36+const BARE = path.join(TMP, 'remote.git');
37+const SEED = path.join(TMP, 'seed');
38+ 
39+function git(cmd: string, cwd: string): void {
40+ execSync(cmd, { cwd, stdio: 'pipe', timeout: 60_000 });
41+}
42+ 
43+function makeRemote(cacheDir: string, paradigmRoots = true): Cannbay2Remote {
44+ // as 断言:pushAuth/lfs 字段按 remote.ts 代际可选(不写 → 对任意代际类型合法)
45+ return { pullUrl: BARE, pushUrl: BARE, branch: 'main', rootDir: 'sessions', cacheDir, paradigmRoots } as Cannbay2Remote;
46+}
47+ 
48+function metaJson(sid: string, data?: Record<string, unknown>): string {
49+ return JSON.stringify({
50+ x_cannbay: {
51+ schema: 'cc-session-meta',
52+ version: 1,
53+ data: { producer: 'cpx', framework: 'opencode', ccVersion: '1.2.3', opGen: 'success', sid, ...data },
54+ },
55+ }, null, 2) + '\n';
56+}
57+ 
58+function jsonlLine(text: string): Buffer {
59+ return zlib.gzipSync(Buffer.from(JSON.stringify({ type: 'user', message: { role: 'user', content: text }, timestamp: '2026-09-15T00:00:00Z' }) + '\n'));
60+}
61+ 
62+function seedCommit(message: string): void {
63+ git('git add .', SEED);
64+ git(`git -c user.name=seeder -c user.email=s@t commit -q -m "${message}"`, SEED);
65+ git('git push -q origin main', SEED);
66+}
67+ 
68+function seedSession(sid: string, files: Record<string, string | Buffer>): void {
69+ const dir = path.join(SEED, 'sessions', sid);
70+ fs.mkdirSync(dir, { recursive: true });
71+ for (const [name, content] of Object.entries(files)) {
72+ fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
73+ fs.writeFileSync(path.join(dir, name), content);
74+ }
75+ seedCommit(`提交人: seeder 内容描述: session ${sid}`);
76+}
77+ 
78+beforeAll(() => {
79+ execSync(`git init --bare -b main "${BARE}"`, { stdio: 'pipe' });
80+ git('git config uploadpack.allowFilter true', BARE);
81+ git('git config uploadpack.allowAnySHA1InWant true', BARE);
82+ execSync(`git clone --quiet "${BARE}" "${SEED}"`, { stdio: 'pipe' });
83+ fs.writeFileSync(path.join(SEED, 'README.md'), '# prefetch test\n');
84+ seedCommit('init');
85+});
86+ 
87+afterAll(() => {
88+ try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* best-effort */ }
89+});
90+ 
91+function newCache(paradigmRoots = true): { cacheDir: string; remote: Cannbay2Remote } {
92+ const cacheDir = fs.mkdtempSync(path.join(TMP, 'cache-'));
93+ return { cacheDir, remote: makeRemote(cacheDir, paradigmRoots) };
94+}
95+ 
96+/** bare 仓内 sessions/<sid>/ 下全部 blob oid(经镜像 ls-tree)。 */
97+function sessionBlobOids(dir: string, sid: string): string[] {
98+ const out = execSync(`git ls-tree -r origin/main -- sessions/${sid}/`, { cwd: dir, encoding: 'utf-8' });
99+ return out.split('\n').map(l => l.match(/blob ([0-9a-f]{40})\t/)?.[1]).filter((o): o is string => !!o);
100+}
101+ 
102+describe('cannbay2 blob 批量预取 + 合并列表(读路径性能)', () => {
103+ it('prefetchBlobs:真 partial clone 上批量拉齐;非法 oid 过滤、不存在 oid 不炸', () => {
104+ const sid = 'ses_prefetch_a';
105+ seedSession(sid, {
106+ [`${sid}.jsonl.gz`]: jsonlLine('prefetch target'),
107+ [`${sid}.meta.json`]: metaJson(sid),
108+ 'subagents/sub-a.jsonl.gz': jsonlLine('sub a'),
109+ });
110+ const { remote } = newCache();
111+ const dir = ensureMirror(remote);
112+ const oids = sessionBlobOids(dir, sid);
113+ expect(oids.length).toBe(3);
114+ for (const oid of oids) expect(localObjectOids(dir).has(oid)).toBe(false);
115+ // 非法 oid(非 40 hex)被白名单过滤;远端不存在的 40-hex fetch 失败被吞
116+ //(fetch 按 want 原子:坏 oid 所在块整体失败 → 该块回退逐对象懒取,不炸)
117+ const bogus = 'deadbeef'.repeat(5);
118+ expect(() => prefetchBlobs(dir, ['not-an-oid', bogus])).not.toThrow();
119+ expect(localObjectOids(dir).has(bogus)).toBe(false);
120+ prefetchBlobs(dir, oids);
121+ const local = localObjectOids(dir);
122+ for (const oid of oids) expect(local.has(oid)).toBe(true);
123+ });
124+ 
125+ it('listSessions:250 会话 meta 跨分块全量回填(framework/producer/opGen/submitter)', () => {
126+ for (let i = 0; i < 250; i++) {
127+ const sid = `ses_bulk_${String(i).padStart(3, '0')}`;
128+ fs.mkdirSync(path.join(SEED, 'sessions', sid), { recursive: true });
129+ fs.writeFileSync(path.join(SEED, 'sessions', sid, `${sid}.meta.json`), metaJson(sid, i === 0 ? { opGen: 'failure' } : {}));
130+ }
131+ seedCommit('提交人: seeder 内容描述: bulk metas');
132+ const { remote } = newCache();
133+ const sessions = listSessions(remote);
134+ const bulk = sessions.filter(s => s.sid.startsWith('ses_bulk_'));
135+ expect(bulk.length).toBe(250);
136+ for (const s of bulk) {
137+ expect(s.framework).toBe('opencode');
138+ expect(s.producer).toBe('cpx');
139+ expect(s.ccVersion).toBe('1.2.3');
140+ expect(s.opGen).toBe(s.sid.endsWith('_000') ? 'failure' : 'success');
141+ expect(s.submitter).toBe('seeder');
142+ }
143+ }, 120_000);
144+ 
145+ it('materializeSession:gzip 二进制安全 + 300 子代理跨批物化逐字节一致', () => {
146+ const sid = 'ses_big_batch';
147+ const files: Record<string, string | Buffer> = {
148+ [`${sid}.jsonl.gz`]: jsonlLine('main binary payload'),
149+ [`${sid}.meta.json`]: metaJson(sid),
150+ };
151+ for (let i = 0; i < 300; i++) files[`subagents/sub-${String(i).padStart(3, '0')}.jsonl.gz`] = jsonlLine(`sub payload ${i}`);
152+ seedSession(sid, files);
153+ const { remote } = newCache();
154+ const mainJsonl = materializeSession(sid, remote);
155+ expect(mainJsonl).toBe(path.join(remote.cacheDir, 'materialized', `${sid}.jsonl.gz`));
156+ const seedDir = path.join(SEED, 'sessions', sid);
157+ expect(fs.readFileSync(mainJsonl)).toEqual(fs.readFileSync(path.join(seedDir, `${sid}.jsonl.gz`)));
158+ expect(fs.readFileSync(path.join(remote.cacheDir, 'materialized', `${sid}.meta.json`)))
159+ .toEqual(fs.readFileSync(path.join(seedDir, `${sid}.meta.json`)));
160+ for (let i = 0; i < 300; i++) {
161+ const name = `sub-${String(i).padStart(3, '0')}.jsonl.gz`;
162+ const got = fs.readFileSync(path.join(remote.cacheDir, 'materialized', sid, 'subagents', name));
163+ expect(got).toEqual(fs.readFileSync(path.join(seedDir, 'subagents', name)));
164+ }
165+ }, 120_000);
166+ 
167+ it('listOverview:sessions + topDirs 单轮 fetch 合并返回,topDirs 与 listRemoteDirs 同源', () => {
168+ const { remote } = newCache();
169+ const overview = listOverview(remote);
170+ expect(overview.sessions.length).toBeGreaterThan(250);
171+ expect(overview.topDirs).toContain('sessions');
172+ expect(overview.topDirs).toEqual(listRemoteDirs(remote));
173+ // 同 mirror 的两份数据引用一致(免第二轮 fetch):再取一次列表,会话集合相同
174+ expect(listSessions(remote).map(s => s.sid).sort()).toEqual(overview.sessions.map(s => s.sid).sort());
175+ });
176+ 
177+ it('listOverview:自定义单根远端 topDirs 恒 [],sessions 照常返回', () => {
178+ const { remote } = newCache(false);
179+ const overview = listOverview(remote);
180+ expect(overview.topDirs).toEqual([]);
181+ expect(overview.sessions.length).toBeGreaterThan(0);
182+ expect(overview.sessions.every(s => s.paradigm === null)).toBe(true);
183+ });
184+});
@@ -10,6 +10,9 @@
10// 分支/目录白名单(shell 注入面)、resolve 优先级(body > env > 硬编码)、10// 分支/目录白名单(shell 注入面)、resolve 优先级(body > env > 硬编码)、
11// localStorage 迁移、错误消息脱敏。11// localStorage 迁移、错误消息脱敏。
12import { describe, it, expect, vi, afterEach } from 'vitest';12import { describe, it, expect, vi, afterEach } from 'vitest';
13+import { execSync } from 'node:child_process';
14+import fs from 'node:fs';
15+import os from 'node:os';
13import path from 'node:path';16import path from 'node:path';
14 17 
15process.env.CANNBAY2_REMOTE_URL = '/tmp/unit-env-remote.git';18process.env.CANNBAY2_REMOTE_URL = '/tmp/unit-env-remote.git';
@@ -150,18 +153,23 @@ describe('resolveCannbay2Remote:body > env > 硬编码默认', () => {
150 it('body 目录替换 sessions 前缀', () => {153 it('body 目录替换 sessions 前缀', () => {
151 expect(remote.resolveCannbay2Remote({ directory: 'trace/2026-08' }).rootDir).toBe('trace/2026-08');154 expect(remote.resolveCannbay2Remote({ directory: 'trace/2026-08' }).rootDir).toBe('trace/2026-08');
152 });155 });
153- it('凭据同时进 pull/push URL', () => {156+ it('凭据:pull 原样内嵌,push 拆为无凭据 URL + pushAuth(atomgit 拒请求直带密码)', () => {
154 const r = remote.resolveCannbay2Remote({ address: 'https://ai.gitcode.com/a/b', authMode: 'token', token: 'tk' });157 const r = remote.resolveCannbay2Remote({ address: 'https://ai.gitcode.com/a/b', authMode: 'token', token: 'tk' });
155 expect(r.pullUrl).toBe('https://oauth2:tk@gitcode.com/a/b');158 expect(r.pullUrl).toBe('https://oauth2:tk@gitcode.com/a/b');
156- expect(r.pushUrl).toBe(r.pullUrl);159+ expect(r.pushUrl).toBe('https://gitcode.com/a/b');
160+ expect(r.pushUrl).not.toContain('@');
161+ expect(r.pushAuth).toEqual({ username: 'oauth2', password: 'tk' });
157 });162 });
158 it('默认仓 push 不被用户凭据劫持(CH.00905403 修复):未配自定义地址时 push 用内置共享令牌,pull 仍可带用户凭据', () => {163 it('默认仓 push 不被用户凭据劫持(CH.00905403 修复):未配自定义地址时 push 用内置共享令牌,pull 仍可带用户凭据', () => {
159 // 场景:客户在设置页配了自己的数据之湖令牌、地址留默认 → 此前164 // 场景:客户在设置页配了自己的数据之湖令牌、地址留默认 → 此前
160 // embedCredentials 会剥掉内置凭据换成客户身份 push 共享仓 → atomgit165 // embedCredentials 会剥掉内置凭据换成客户身份 push 共享仓 → atomgit
161 // 报"not allowed to push code to this project"(身份合法、无写权限)166 // 报"not allowed to push code to this project"(身份合法、无写权限)
162 const r = remote.resolveCannbay2Remote({ authMode: 'token', account: 'customer', token: 'their-token' });167 const r = remote.resolveCannbay2Remote({ authMode: 'token', account: 'customer', token: 'their-token' });
163- expect(r.pushUrl).toBe(remote.CANNBAY2_PUSH_URL); // 内置共享令牌原样保留168+ // 内置共享令牌不进 push URL:拆为无凭据 URL + pushAuth(401 挑战-应答通道)
164- expect(r.pushUrl).not.toContain('their-token');169+ expect(r.pushUrl).toBe(remote.parseUrlCreds(remote.CANNBAY2_PUSH_URL).credless);
170+ expect(r.pushUrl).not.toContain('@');
171+ expect(r.pushAuth?.username).toBe(remote.parseUrlCreds(remote.CANNBAY2_PUSH_URL).auth?.username);
172+ expect(r.pushAuth?.password).not.toBe('their-token');
165 // pull 保持既有语义:http 地址带用户凭据(embedCredentials 单测已覆盖),173 // pull 保持既有语义:http 地址带用户凭据(embedCredentials 单测已覆盖),
166 // 本测试 env 的 pull 是本地路径(非 http 不嵌凭据),验证不被改写即可174 // 本测试 env 的 pull 是本地路径(非 http 不嵌凭据),验证不被改写即可
167 expect(r.pullUrl).toBe('/tmp/unit-env-remote.git');175 expect(r.pullUrl).toBe('/tmp/unit-env-remote.git');
@@ -187,6 +195,66 @@ describe('resolveCannbay2Remote:body > env > 硬编码默认', () => {
187 });195 });
188});196});
189 197 
198+describe('parseUrlCreds:内嵌凭据拆分(push 走 helper 通道的前提)', () => {
199+ it('https://user:pass@host → 无凭据 URL + auth 对', () => {
200+ const a = remote.parseUrlCreds('https://duanbingzhe:tok@atomgit.com/duanbingzhe/cannbay3.git');
201+ expect(a.credless).toBe('https://atomgit.com/duanbingzhe/cannbay3.git');
202+ expect(a.auth).toEqual({ username: 'duanbingzhe', password: 'tok' });
203+ });
204+ it('URL 编码凭据解码(embedCredentials 写入侧 encodeURIComponent)', () => {
205+ const b = remote.parseUrlCreds('https://oauth2:tok%40x%3A@host/repo.git');
206+ expect(b.auth).toEqual({ username: 'oauth2', password: 'tok@x:' });
207+ expect(b.credless).toBe('https://host/repo.git');
208+ });
209+ it('无凭据 https / 本地路径 / ssh 透传,auth=null', () => {
210+ for (const u of ['https://atomgit.com/a/b.git', '/tmp/x.git', 'git@host:org/repo.git']) {
211+ expect(remote.parseUrlCreds(u)).toEqual({ credless: u, auth: null });
212+ }
213+ });
214+});
215+ 
216+describe('pushMirror:401 挑战-应答通道(本地 bare IT)', () => {
217+ it('pushAuth 存在:一次性 helper 构造/清理 + push 成功(本地传输不咨询凭据,锁命令构造与生命周期)', () => {
218+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'insight-pushmirror-it-'));
219+ try {
220+ const bare = path.join(tmp, 'bare.git');
221+ execSync(`git init --bare -b main "${bare}"`, { stdio: 'pipe' });
222+ const work = path.join(tmp, 'work');
223+ fs.mkdirSync(work, { recursive: true });
224+ const git = (cmd: string) => execSync(cmd, { cwd: work, stdio: 'pipe' });
225+ git('git init -b main .');
226+ fs.writeFileSync(path.join(work, 'f.txt'), 'x');
227+ git('git -c user.name=t -c user.email=t@x add -A');
228+ git('git -c user.name=t -c user.email=t@x commit -m m');
229+ const before = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('insight-cannbay2-auth-'));
230+ const r = remote.resolveCannbay2Remote({ address: bare, branch: 'main' });
231+ expect(r.pushAuth).toBeNull(); // 本地路径无凭据
232+ const withAuth: remote.Cannbay2Remote = { ...r, pushAuth: { username: 'it-user', password: 'it-pass' } };
233+ mirrorMod.pushMirror(work, withAuth);
234+ expect(execSync('git rev-parse main', { cwd: bare, stdio: 'pipe' }).toString().trim())
235+ .toBe(execSync('git rev-parse HEAD', { cwd: work, stdio: 'pipe' }).toString().trim());
236+ const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('insight-cannbay2-auth-'));
237+ expect(after).toEqual(before); // helper 临时目录用后即删
238+ // pushAuth=null 直推路径
239+ fs.writeFileSync(path.join(work, 'g.txt'), 'y');
240+ git('git -c user.name=t -c user.email=t@x add -A');
241+ git('git -c user.name=t -c user.email=t@x commit -m m2');
242+ mirrorMod.pushMirror(work, r);
243+ expect(execSync('git rev-parse main', { cwd: bare, stdio: 'pipe' }).toString().trim())
244+ .toBe(execSync('git rev-parse HEAD', { cwd: work, stdio: 'pipe' }).toString().trim());
245+ } finally {
246+ fs.rmSync(tmp, { recursive: true, force: true });
247+ }
248+ });
249+ it('pushAuth 含换行:helper 协议非法,立即拒绝且不落临时目录', () => {
250+ const r = remote.resolveCannbay2Remote({ address: '/tmp/x.git', branch: 'main' });
251+ const bad: remote.Cannbay2Remote = { ...r, pushAuth: { username: 'u', password: 'p\nq' } };
252+ const before = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('insight-cannbay2-auth-'));
253+ expect(() => mirrorMod.pushMirror('/tmp', bad)).toThrow('换行');
254+ expect(fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('insight-cannbay2-auth-'))).toEqual(before);
255+ });
256+});
257+ 
190describe('cannbay-config:迁移与请求体裁剪', () => {258describe('cannbay-config:迁移与请求体裁剪', () => {
191 it('legacy {address,account,password} → 地址保留 + authMode password', () => {259 it('legacy {address,account,password} → 地址保留 + authMode password', () => {
192 const m = migrateCannbayConfig({ address: 'https://gitcode.com/a/b', account: 'u', password: 'p' });260 const m = migrateCannbayConfig({ address: 'https://gitcode.com/a/b', account: 'u', password: 'p' });
@@ -0,0 +1,46 @@
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 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.
8+ 
9+// CLI 版本旗标(npm 全局安装后的用户直觉输入):
10+// -v / --version 输出 `CANNBot-Insight v<x.y.z>`(与 cpx 的 `cpx v0.1.x (路径)`
11+// 风格对齐);版本串单一真身在 version.ts,这里锁绑定与组装不漂移。
12+// bin/cannbot-insight.mjs 的透传名单须放行 -v——否则 `cbi -v` 掉进
13+// "无参数 → 启动 Web UI" 分支(名单断言防回归)。
14+import { describe, it, expect } from 'vitest';
15+import fs from 'node:fs';
16+import path from 'node:path';
17+import { createProgram } from '@/cli/index';
18+import { VERSION, VERSION_DISPLAY } from '@/lib/version';
19+import { BRAND_NAME } from '@/lib/branding';
20+ 
21+describe('CLI 版本旗标', () => {
22+ it('-v 与 --version 绑定在同一 version 选项上', () => {
23+ const program = createProgram();
24+ const versionOption = program.options.find(o => o.long === '--version');
25+ expect(versionOption).toBeDefined();
26+ expect(versionOption!.short).toBe('-v');
27+ expect(versionOption!.flags).toBe('-v, --version');
28+ });
29+ 
30+ it('版本输出带品牌前缀且与 version.ts 单源一致', () => {
31+ const program = createProgram();
32+ expect(program.version()).toBe(`${BRAND_NAME} ${VERSION_DISPLAY}`);
33+ expect(program.version()).toContain(VERSION);
34+ });
35+ 
36+ it('bin 透传名单放行 -v(否则 cbi -v 误启 Web UI)', () => {
37+ const bin = fs.readFileSync(
38+ path.resolve(__dirname, '../../../../bin/cannbot-insight.mjs'), 'utf8',
39+ );
40+ const m = bin.match(/const CLI_COMMANDS = new Set\(\[([\s\S]*?)\]\)/);
41+ expect(m).toBeDefined();
42+ for (const flag of ["'-v'", "'--version'", "'-V'"]) {
43+ expect(m![1]).toContain(flag);
44+ }
45+ });
46+});
@@ -0,0 +1,115 @@
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+// context 品牌变体发布链路 IT:pack-context.mjs 打包 → 解包 → 品牌切换断言 +
10+// 格式契约保留断言 + 品牌泄漏哨兵(源码品牌字面量漂移会让打包期 minHits 先炸,
11+// 这里兜底扫产物,双保险)。
12+ 
13+import { describe, it, expect, beforeAll, afterAll } from 'vitest'
14+import { spawnSync } from 'node:child_process'
15+import fs from 'node:fs'
16+import os from 'node:os'
17+import path from 'node:path'
18+import { fileURLToPath } from 'node:url'
19+import { VERSION } from '../../src/lib/version'
20+ 
21+const PKG_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
22+const TGZ = path.join(PKG_DIR, '.context-pack', `context-insight-${VERSION}.tgz`)
23+ 
24+const TEXT_EXTS = new Set([
25+ '.ts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.py',
26+ '.css', '.scss', '.html', '.txt', '.sh', '.yml', '.yaml', '.sql',
27+ '.prisma', '.xml', '.svg', '.example',
28+])
29+ 
30+let extracted = ''
31+let tmpRoot = ''
32+ 
33+function read(rel: string): string {
34+ return fs.readFileSync(path.join(extracted, rel), 'utf8')
35+}
36+ 
37+// needle:禁止出现的品牌字面量;allow:先从文本中剥除的豁免串(如 v1 路由
38+// 硬编码的真实仓 URL——guanxinghua/CANNBay.git 是仓库名不是品牌显示,替换会断链)
39+const FORBIDDEN: Array<{ needle: string; allow?: string }> = [
40+ { needle: 'CANNBot-Insight' },
41+ { needle: 'CANNbay' },
42+ { needle: 'CANNBay', allow: 'guanxinghua/CANNBay.git' },
43+ { needle: '[cbi]' },
44+ { needle: '.cannbot-insight' },
45+]
46+ 
47+function scanForbidden(root: string, forbidden: Array<{ needle: string; allow?: string }>): string[] {
48+ const leaks: string[] = []
49+ const walk = (dir: string) => {
50+ for (const name of fs.readdirSync(dir)) {
51+ if (name === 'node_modules') continue
52+ const full = path.join(dir, name)
53+ if (fs.statSync(full).isDirectory()) { walk(full); continue }
54+ if (!TEXT_EXTS.has(path.extname(name))) continue
55+ const raw = fs.readFileSync(full, 'utf8')
56+ for (const bad of forbidden) {
57+ const text = bad.allow ? raw.split(bad.allow).join('') : raw
58+ if (text.includes(bad.needle)) leaks.push(`${path.relative(root, full)}: ${bad.needle}`)
59+ }
60+ }
61+ }
62+ walk(root)
63+ return leaks
64+}
65+ 
66+describe('context 品牌变体打包(pack-context.mjs)', () => {
67+ beforeAll(() => {
68+ const r = spawnSync(process.execPath, ['scripts/pack-context.mjs'], {
69+ cwd: PKG_DIR, encoding: 'utf8', timeout: 240_000, maxBuffer: 32 * 1024 * 1024,
70+ })
71+ expect(r.status, `pack-context.mjs 失败:\n${r.stderr}`).toBe(0)
72+ expect(fs.existsSync(TGZ), `产物缺失: ${TGZ}`).toBe(true)
73+ 
74+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ctx-insight-'))
75+ const t = spawnSync('tar', ['-xzf', TGZ, '-C', tmpRoot], { encoding: 'utf8', timeout: 60_000 })
76+ expect(t.status, t.stderr).toBe(0)
77+ extracted = path.join(tmpRoot, 'package')
78+ }, 300_000)
79+ 
80+ afterAll(() => {
81+ try { fs.rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* tmp 目录留置无害 */ }
82+ })
83+ 
84+ it('package.json:包名 / bin / 版本与主包同源', () => {
85+ const pkg = JSON.parse(read('package.json'))
86+ expect(pkg.name).toBe('context-insight')
87+ expect(pkg.bin).toEqual({
88+ 'context-insight': './bin/context-insight.mjs',
89+ ctxi: './bin/context-insight.mjs',
90+ })
91+ expect(pkg.version).toBe(VERSION)
92+ expect(pkg.private).toBeUndefined()
93+ })
94+ 
95+ it('CLI bundle 实跑:--version 输出 Context-Insight 品牌串', () => {
96+ fs.symlinkSync(path.join(PKG_DIR, 'node_modules'), path.join(extracted, 'node_modules'), 'dir')
97+ const r = spawnSync(process.execPath, ['dist/cli.mjs', '--version'], {
98+ cwd: extracted, encoding: 'utf8', timeout: 60_000,
99+ })
100+ expect(r.status, r.stderr).toBe(0)
101+ expect(r.stdout.trim()).toBe(`Context-Insight v${VERSION}`)
102+ })
103+ 
104+ it('品牌泄漏哨兵:显示名 / Bay 名(大小写两种形态)/ 别名 / 数据目录路径零残留', () => {
105+ const leaks = scanForbidden(extracted, FORBIDDEN)
106+ expect(leaks, `品牌泄漏:\n${leaks.join('\n')}`).toEqual([])
107+ })
108+ 
109+ it('格式契约保留:source type / smart-agent 服务标识 / CANNBAY2 env 文档', () => {
110+ expect(read('src/lib/branding.ts')).toContain('BRAND_SOURCE_TYPE = "cannbot-insight"')
111+ expect(read('smart-agent/server.py')).toContain('cannbot-smart-agent')
112+ expect(read('README.md')).toContain('CANNBAY2_LFS')
113+ expect(read('bin/context-insight.mjs')).toContain("'.context-insight'")
114+ })
115+})
@@ -24,6 +24,7 @@ import fs from "node:fs"
24import path from "node:path"24import path from "node:path"
25import { fileURLToPath } from "node:url"25import { fileURLToPath } from "node:url"
26import { VERSION } from "../../src/lib/version"26import { VERSION } from "../../src/lib/version"
27+import { BRAND_NAME } from "../../src/lib/branding"
27 28 
28const __dirname = path.dirname(fileURLToPath(import.meta.url))29const __dirname = path.dirname(fileURLToPath(import.meta.url))
29const PKG_DIR = path.resolve(__dirname, "../..")30const PKG_DIR = path.resolve(__dirname, "../..")
@@ -117,6 +118,7 @@ describe("npm publish readiness (npm pack --dry-run)", () => {
117 expect(build.status, `build-cli.mjs 失败:\n${build.stderr}`).toBe(0)118 expect(build.status, `build-cli.mjs 失败:\n${build.stderr}`).toBe(0)
118 const r = spawnSync(process.execPath, ["dist/cli.mjs", "--version"], { cwd: PKG_DIR, encoding: "utf8", timeout: 60_000 })119 const r = spawnSync(process.execPath, ["dist/cli.mjs", "--version"], { cwd: PKG_DIR, encoding: "utf8", timeout: 60_000 })
119 expect(r.status, `dist/cli.mjs --version 失败:\n${r.stderr}`).toBe(0)120 expect(r.status, `dist/cli.mjs --version 失败:\n${r.stderr}`).toBe(0)
120- expect(r.stdout.trim()).toBe(VERSION)121+ // 品牌前缀格式(`CANNBot-Insight v0.2.x`,与 cpx 风格对齐),版本号单源在 VERSION
122+ expect(r.stdout.trim()).toBe(`${BRAND_NAME} v${VERSION}`)
121 }, 120_000)123 }, 120_000)
122})124})
@@ -18,6 +18,7 @@ import { createRoot, type Root } from 'react-dom/client'
18import { act } from 'react'18import { act } from 'react'
19import { SessionList } from '@/components/SessionList'19import { SessionList } from '@/components/SessionList'
20import { LocalFileImport } from '@/components/LocalFileImport'20import { LocalFileImport } from '@/components/LocalFileImport'
21+import { BAY_NAME } from '@/lib/branding'
21 22 
22vi.mock('next/navigation', () => ({23vi.mock('next/navigation', () => ({
23 useRouter: () => ({ push: () => {}, replace: () => {}, back: () => {}, refresh: () => {} }),24 useRouter: () => ({ push: () => {}, replace: () => {}, back: () => {}, refresh: () => {} }),
@@ -37,7 +38,9 @@ beforeAll(() => {
37 const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>38 const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>
38 if (body.action === 'dirs') return json({ dirs: ['others'] })39 if (body.action === 'dirs') return json({ dirs: ['others'] })
39 if (body.action === 'list') {40 if (body.action === 'list') {
40- return json({ sessions: [41+ // list 响应带 dirs(顶层目录):合并契约——后端单轮 mirror fetch
42+ // 同时服务范式下拉与会话表
43+ return json({ dirs: ['sessions', 'others'], sessions: [
41 { sid: 'sid-ok', fileCount: 2, author: 'a', submitter: '测试者', description: '成功的会话', commitTime: '2026-09-02T03:00:00Z', framework: 'claude-code', producer: 'cpx', paradigm: 'others', opGen: 'success' },44 { sid: 'sid-ok', fileCount: 2, author: 'a', submitter: '测试者', description: '成功的会话', commitTime: '2026-09-02T03:00:00Z', framework: 'claude-code', producer: 'cpx', paradigm: 'others', opGen: 'success' },
42 { sid: 'sid-fail', fileCount: 2, author: 'a', submitter: '测试者', description: '失败的会话', commitTime: '2026-09-02T02:00:00Z', framework: 'opencode', producer: 'cpx', paradigm: 'others', opGen: 'failure' },45 { sid: 'sid-fail', fileCount: 2, author: 'a', submitter: '测试者', description: '失败的会话', commitTime: '2026-09-02T02:00:00Z', framework: 'opencode', producer: 'cpx', paradigm: 'others', opGen: 'failure' },
43 { sid: 'sid-plain', fileCount: 2, author: 'a', submitter: '测试者', description: '未标注的会话', commitTime: '2026-09-02T01:00:00Z', framework: 'opencode', producer: 'cpx', paradigm: 'others' },46 { sid: 'sid-plain', fileCount: 2, author: 'a', submitter: '测试者', description: '未标注的会话', commitTime: '2026-09-02T01:00:00Z', framework: 'opencode', producer: 'cpx', paradigm: 'others' },
@@ -89,8 +92,8 @@ describe('算子生成标注 UI', () => {
89 await act(async () => { root!.render(createElement(SessionList, { items: [ITEM], total: 1, page: 1, pageSize: 16 })) })92 await act(async () => { root!.render(createElement(SessionList, { items: [ITEM], total: 1, page: 1, pageSize: 16 })) })
90 await flush()93 await flush()
91 94 
92- // 打开上传弹窗(行内 Upload 按钮,title=Upload to CANNBay)95+ // 打开上传弹窗(行内 Upload 按钮,title 跟随 BAY_NAME 品牌)
93- const uploadBtn = document.querySelector('button[title="Upload to CANNBay"]') as HTMLButtonElement96+ const uploadBtn = document.querySelector(`button[title="Upload to ${BAY_NAME}"]`) as HTMLButtonElement
94 expect(uploadBtn, '上传按钮未找到').toBeTruthy()97 expect(uploadBtn, '上传按钮未找到').toBeTruthy()
95 await act(async () => { click(uploadBtn) })98 await act(async () => { click(uploadBtn) })
96 await flush()99 await flush()
@@ -136,7 +139,7 @@ describe('算子生成标注 UI', () => {
136 await act(async () => { root!.render(createElement(SessionList, { items: [ITEM], total: 1, page: 1, pageSize: 16 })) })139 await act(async () => { root!.render(createElement(SessionList, { items: [ITEM], total: 1, page: 1, pageSize: 16 })) })
137 await flush()140 await flush()
138 141 
139- await act(async () => { click(document.querySelector('button[title="Upload to CANNBay"]')!) })142+ await act(async () => { click(document.querySelector(`button[title="Upload to ${BAY_NAME}"]`)!) })
140 await flush()143 await flush()
141 144 
142 const select = document.querySelector('select') as HTMLSelectElement145 const select = document.querySelector('select') as HTMLSelectElement
@@ -169,7 +172,7 @@ describe('算子生成标注 UI', () => {
169 172 
170 await act(async () => { click(findButtonByText('Import Session')!) })173 await act(async () => { click(findButtonByText('Import Session')!) })
171 await flush()174 await flush()
172- await act(async () => { click(findButtonByText('CANNBay')!) })175+ await act(async () => { click(findButtonByText(BAY_NAME)!) })
173 await flush()176 await flush()
174 // 范式选择步 → 点 others177 // 范式选择步 → 点 others
175 await act(async () => { click(findButtonByText('others')!) })178 await act(async () => { click(findButtonByText('others')!) })