已合并
[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
已合并
共 55 个文件变更+1951-159
| @@ -33,6 +33,8 @@ tests/**/sandboxes/ | |||
| 33 | *.sqlite3 | 33 | *.sqlite3 |
| 34 | captures/ | 34 | captures/ |
| 35 | *.tgz | 35 | *.tgz |
| 36 | +.context-pack/ | ||
| 37 | +**/.context-pack/ | ||
| 36 | 38 | ||
| 37 | # IDE 与系统文件 | 39 | # IDE 与系统文件 |
| 38 | .idea/ | 40 | .idea/ |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | ||
| 7 | CANNBot-Sentry 是面向 LLM 编码 Agent(opencode / Claude Code)的**观测 · 审计 · 评测**哨站。Agent 在场上跑,Sentry 在场边:**记录现场 · 复盘事实 · 评鉴质量**。 | 7 | CANNBot-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 | ```bash | 33 | ```bash |
| 34 | npm install -g cannbot-insight # insight 观测平台(cannbot-insight / cbi) | 34 | npm install -g cannbot-insight # insight 观测平台(cannbot-insight / cbi) |
| 35 | npm install -g cannbot-proxy # cpx 捕获代理(cpx) | 35 | npm 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 | ||
| 38 | Tab 补全在安装时自动装配(bash/zsh),新开终端 `cpx <TAB>` 即可用。 | 41 | Tab 补全在安装时自动装配(bash/zsh),新开终端 `cpx <TAB>` 即可用。 |
| @@ -41,11 +44,11 @@ Tab 补全在安装时自动装配(bash/zsh),新开终端 `cpx <TAB>` 即 | |||
| 41 | 44 | ||
| 42 | ```bash | 45 | ```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 | ||
| 7 | CANNBot-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**. | 7 | CANNBot-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 Capabilities | 11 | ## Core Capabilities |
| 12 | 12 | ||
| @@ -33,6 +33,9 @@ cd cannbot-sentry | |||
| 33 | ```bash | 33 | ```bash |
| 34 | npm install -g cannbot-insight # insight Web UI (cannbot-insight / cbi) | 34 | npm install -g cannbot-insight # insight Web UI (cannbot-insight / cbi) |
| 35 | npm install -g cannbot-proxy # cpx capture proxy (cpx) | 35 | npm 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 | ||
| 38 | Shell tab completion is configured automatically on install (bash/zsh); open a new terminal and `cpx <TAB>` just works. | 41 | Shell 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 | ```bash | 45 | ```bash |
| 43 | ./install.sh insight # single package | 46 | ./install.sh insight # single package |
| 44 | -./install.sh cpx sift # combination | 47 | +./install.sh insight cpx # combination |
| 45 | ./install.sh all # everything | 48 | ./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 Structure | 53 | ## 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-14 | 12 | ## [v0.1.3] - 2026-09-14 |
| 6 | 13 | ||
| 7 | ### Changed | 14 | ### Changed |
| @@ -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 | ||
| 24 | export const MAX_SESSION_BYTES = 100 * 1024 * 1024; | 24 | export 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 | + | ||
| 26 | export class GovernanceError extends Error {} | 34 | export class GovernanceError extends Error {} |
| 27 | 35 | ||
| 28 | interface CcSessionMeta { | 36 | interface 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 === true | 128 | 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'; |
| 17 | import { randomUUID } from 'node:crypto'; | 17 | import { randomUUID } from 'node:crypto'; |
| 18 | import fs from 'node:fs'; | 18 | import fs from 'node:fs'; |
| 19 | +import os from 'node:os'; | ||
| 19 | import path from 'node:path'; | 20 | import path from 'node:path'; |
| 20 | import { | 21 | import { |
| 21 | resolveCannbayRemote, stripUrlCreds, escapeRegExp, type CannbayRemote, | 22 | resolveCannbayRemote, stripUrlCreds, escapeRegExp, type CannbayRemote, |
| @@ -27,8 +28,11 @@ import { | |||
| 27 | // 操作对"装没装 git-lfs"完全不敏感 | 28 | // 操作对"装没装 git-lfs"完全不敏感 |
| 28 | export const LFS_NEUTRALIZE = '-c filter.lfs.process= -c filter.lfs.smudge=cat -c filter.lfs.clean=cat -c filter.lfs.required=false'; | 29 | export 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 | + | ||
| 211 | export interface UploadResult { | 261 | export 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)。 | ||
| 28 | const CANNBAY2_PUSH_URL_ENCODED = 'aHR0cHM6Ly9kdWFuYmluZ3poZTpvU1I1aTF3WXZ5dlZpUmdMbXdna3NDeG5AYXRvbWdpdC5jb20vZHVhbmJpbmd6aGUvY2FubmJheTMuZ2l0'; | 31 | const CANNBAY2_PUSH_URL_ENCODED = 'aHR0cHM6Ly9kdWFuYmluZ3poZTpvU1I1aTF3WXZ5dlZpUmdMbXdna3NDeG5AYXRvbWdpdC5jb20vZHVhbmJpbmd6aGUvY2FubmJheTMuZ2l0'; |
| 29 | 32 | ||
| 30 | export function envPushUrl(): string { | 33 | export 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 | + | ||
| 38 | export interface CannbayRemote { | 65 | export 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 | ||
| 47 | const FORBIDDEN_ADDRESS_CHARS = /["'`$;&|\n\r]/; | 79 | const 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 | ||
| 108 | export function redactRemoteSecrets(message: string): string { | 145 | export function redactRemoteSecrets(message: string): string { |
| 109 | - return message | 146 | + return message.replace(/https:\/\/[^/\s"']+@/g, 'https://<creds>@'); |
| 110 | - .replace(envPushUrl(), '<push-url>') | ||
| 111 | - .replace(/https:\/\/[^/\s"']+@/g, 'https://<creds>@'); | ||
| 112 | } | 147 | } |
| 113 | 148 | ||
| 114 | export function ensureCacheParent(dir: string): void { | 149 | export 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" |
| 10 | export const VERSION_DISPLAY = `v${VERSION}` | 10 | export 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'; | |||
| 21 | import { governText } from '../../src/cannbay/govern'; | 21 | import { governText } from '../../src/cannbay/govern'; |
| 22 | import { extractSubmitter } from '../../src/cannbay'; | 22 | import { extractSubmitter } from '../../src/cannbay'; |
| 23 | import { LFS_NEUTRALIZE } from '../../src/cannbay/mirror'; | 23 | import { LFS_NEUTRALIZE } from '../../src/cannbay/mirror'; |
| 24 | -import { envPushUrl } from '../../src/cannbay/remote'; | 24 | +import { envPushUrl, resolveCannbayRemote } from '../../src/cannbay/remote'; |
| 25 | import { governText as insightGovernText } from '../../../insight/src/lib/cannbay2/governance'; | 25 | import { governText as insightGovernText } from '../../../insight/src/lib/cannbay2/governance'; |
| 26 | 26 | ||
| 27 | const INSIGHT_MIRROR = path.resolve(__dirname, '../../../insight/src/lib/cannbay2/mirror.ts'); | 27 | const 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 | +}) | ||
| @@ -154,6 +154,7 @@ proxy(cpx 捕获)是 insight 的扩展来源,不是核心管线的一部 | |||
| 154 | ## Constraints & Gotchas | 154 | ## Constraints & Gotchas |
| 155 | 155 | ||
| 156 | - **cannbay2 镜像的 git 命令必须走 `runGit`**(mirror.ts):其 per-command 配置中和 lfs filter(`filter.lfs.process= / smudge=cat / clean=cat / required=false`)+ `push --no-verify`。数据仓 `.gitattributes` 历史上带 `*.gz filter=lfs`,装 git-lfs 的客户机会在 checkout(孤儿指针 smudge 403)与 add(clean 指针化)两条路上炸;绕过 runGit 直拼 git 命令会重新暴露(IT:`tests/cannbay2-lfs-immunity.test.ts`) | 156 | - **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 only | 159 | - **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 components | 160 | - **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-14 | 12 | ## [v0.2.28] - 2026-09-14 |
| 6 | 13 | ||
| 7 | ### Added | 14 | ### Added |
| @@ -99,6 +99,7 @@ proxy(cpx 捕获)是 insight 的扩展来源,不是核心管线的一部 | |||
| 99 | 99 | ||
| 100 | ## Constraints & Gotchas | 100 | ## 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 only | 104 | - **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 components | 105 | - **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 UI | 51 | ### 方式一:源码运行 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 source | 51 | ### 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 Analysis | 69 | ### Option 2: CLI Upload + Web Analysis |
| 68 | 70 | ||
| @@ -30,7 +30,7 @@ const BASE_PORT = Number.parseInt(process.env.CANNBOT_INSIGHT_PORT || '21025', 1 | |||
| 30 | const CLI_COMMANDS = new Set([ | 30 | const 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 | ||
| 36 | const args = process.argv.slice(2) | 36 | const 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 | |||
| 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 | ||
| 9 | import { NextRequest, NextResponse } from 'next/server'; | 9 | import { NextRequest, NextResponse } from 'next/server'; |
| 10 | -import { listCannbay2Sessions, importCannbay2Session, uploadCannbay2Session, GovernanceError } from '@/lib/cannbay2'; | 10 | +import { listCannbay2Overview, importCannbay2Session, uploadCannbay2Session, GovernanceError } from '@/lib/cannbay2'; |
| 11 | import { resolveCannbay2Remote, testCannbay2Remote, listRemoteBranches, redactRemoteSecrets, normalizeDirPrefix } from '@/lib/cannbay2/remote'; | 11 | import { resolveCannbay2Remote, testCannbay2Remote, listRemoteBranches, redactRemoteSecrets, normalizeDirPrefix } from '@/lib/cannbay2/remote'; |
| 12 | import { listRemoteDirs } from '@/lib/cannbay2/mirror'; | 12 | import { listRemoteDirs } from '@/lib/cannbay2/mirror'; |
| 13 | import type { CannbayRemoteInput } from '@/lib/cannbay-config'; | 13 | import 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 | ||
| 9 | import { NextRequest, NextResponse } from 'next/server'; | 9 | import { NextRequest, NextResponse } from 'next/server'; |
| 10 | import { importSession } from '@/lib/ingest/data-service'; | 10 | import { importSession } from '@/lib/ingest/data-service'; |
| 11 | -import { BRAND_SOURCE_TYPE } from '@/lib/branding'; | 11 | +import { BRAND_SOURCE_TYPE, BAY_NAME } from '@/lib/branding'; |
| 12 | import { prisma } from '@/lib/db'; | 12 | import { prisma } from '@/lib/db'; |
| 13 | import fs from 'node:fs'; | 13 | import fs from 'node:fs'; |
| 14 | import path from 'node:path'; | 14 | import 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'; | |||
| 15 | import os from 'node:os'; | 15 | import os from 'node:os'; |
| 16 | import { execSync } from 'node:child_process'; | 16 | import { execSync } from 'node:child_process'; |
| 17 | import { planArchive, MAX_MASTER_SESSIONS, type FileWithDate } from '@/lib/cannbay-archive'; | 17 | import { planArchive, MAX_MASTER_SESSIONS, type FileWithDate } from '@/lib/cannbay-archive'; |
| 18 | +import { BAY_NAME } from '@/lib/branding'; | ||
| 18 | 19 | ||
| 19 | const CANNBAY_PULL_URL = 'https://gitcode.com/guanxinghua/CANNBay.git'; | 20 | const CANNBAY_PULL_URL = 'https://gitcode.com/guanxinghua/CANNBay.git'; |
| 20 | const CANNBAY_PUSH_URL = Buffer.from('aHR0cHM6Ly9ndWFueGluZ2h1YTpwc3F5WXAyYnpFRkI0eDVQRlVTV0dMS3lAZ2l0Y29kZS5jb20vZ3VhbnhpbmdodWEvQ0FOTkJheS5naXQ=', 'base64').toString(); | 21 | const 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 only | 48 | +// 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.6GB | 49 | // `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 shrinks | 50 | // 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 clone | 51 | // 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 | |||
| 19 | import { cn } from "@/lib/utils" | 19 | import { cn } from "@/lib/utils" |
| 20 | import { toast } from "sonner" | 20 | import { toast } from "sonner" |
| 21 | import { VERSION_DISPLAY } from "@/lib/version" | 21 | import { VERSION_DISPLAY } from "@/lib/version" |
| 22 | -import { BRAND_NAME } from "@/lib/branding" | 22 | +import { BRAND_NAME, BAY_NAME } from "@/lib/branding" |
| 23 | import type { TurnHighlight } from "@/lib/shared/highlight" | 23 | import type { TurnHighlight } from "@/lib/shared/highlight" |
| 24 | import { TurnTimeline } from "@/components/observe/TurnTimeline" | 24 | import { TurnTimeline } from "@/components/observe/TurnTimeline" |
| 25 | import { TurnDetail } from "@/components/observe/TurnDetail" | 25 | import { TurnDetail } from "@/components/observe/TurnDetail" |
| @@ -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'; | |||
| 11 | import { renderTable, TableColumn } from '../utils/table'; | 11 | import { renderTable, TableColumn } from '../utils/table'; |
| 12 | import { formatDate } from '../utils/format'; | 12 | import { formatDate } from '../utils/format'; |
| 13 | import { formatHeader, formatDivider, formatSuccess, formatWarning, theme } from '../utils/colors'; | 13 | import { formatHeader, formatDivider, formatSuccess, formatWarning, theme } from '../utils/colors'; |
| 14 | +import { BAY_NAME } from '@/lib/branding'; | ||
| 14 | import type { ApiImportableSession, ApiImportableSessionsResponse } from '../types'; | 15 | import type { ApiImportableSession, ApiImportableSessionsResponse } from '../types'; |
| 15 | import readline from 'node:readline'; | 16 | import readline from 'node:readline'; |
| 16 | import { execSync } from 'node:child_process'; | 17 | import { execSync } from 'node:child_process'; |
| @@ -176,7 +177,7 @@ async function selectPrompt(sessions: ApiImportableSession[]): Promise<ApiImport | |||
| 176 | export function uploadCommand(): Command { | 177 | export function uploadCommand(): Command { |
| 177 | const cmd = new Command('upload'); | 178 | const cmd = new Command('upload'); |
| 178 | cmd | 179 | 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 && baseline | 355 | const uploadResult = paradigm && baseline |
| 355 | ? await client.uploadSession(target.id, description, paradigm, true) | 356 | ? await client.uploadSession(target.id, description, paradigm, true) |
| 356 | : paradigm | 357 | : 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 | ||
| 9 | import { Command } from 'commander'; | 9 | import { 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'; | ||
| 12 | import { DEFAULT_SERVER_URL, loadConfig } from './config'; | 14 | import { DEFAULT_SERVER_URL, loadConfig } from './config'; |
| 13 | import { runTui } from './tui/App'; | 15 | import { runTui } from './tui/App'; |
| 14 | import { sessionsCommand } from './commands/sessions'; | 16 | import { 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 @@ | |||
| 9 | import { useEffect, useState } from 'react'; | 9 | import { useEffect, useState } from 'react'; |
| 10 | import { Box, Text, useInput } from 'ink'; | 10 | import { Box, Text, useInput } from 'ink'; |
| 11 | import { TextInput } from './TextInput'; | 11 | import { TextInput } from './TextInput'; |
| 12 | +import { BAY_NAME } from '@/lib/branding'; | ||
| 12 | import type { InsightClient } from '@/cli/client'; | 13 | import type { InsightClient } from '@/cli/client'; |
| 13 | 14 | ||
| 14 | interface UploadPanelProps { | 15 | interface UploadPanelProps { |
| @@ -20,7 +21,7 @@ interface UploadPanelProps { | |||
| 20 | 21 | ||
| 21 | type Phase = 'paradigm' | 'submitter' | 'uploading'; | 22 | type Phase = 'paradigm' | 'submitter' | 'uploading'; |
| 22 | 23 | ||
| 23 | -// CANNBay 上传面板:① 范式目录选择(数据仓顶层目录即注册表,↑↓ 移动 Enter | 24 | +// 数据仓上传面板:① 范式目录选择(数据仓顶层目录即注册表,↑↓ 移动 Enter |
| 24 | // 确认)→ ② 提交人输入(网关强制非空)→ ③ 上传。Esc 任意阶段取消。 | 25 | // 确认)→ ② 提交人输入(网关强制非空)→ ③ 上传。Esc 任意阶段取消。 |
| 25 | // 修复旧版 ConfirmDialog 直传空 description 被提交人网关拒绝的缺陷。 | 26 | // 修复旧版 ConfirmDialog 直传空 description 被提交人网关拒绝的缺陷。 |
| 26 | export function UploadPanel({ client, taskId, onDone, onCancel }: UploadPanelProps) { | 27 | export 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 "{taskId}" to CANNBay</Text> | 85 | + <Text bold>Upload session "{taskId}" 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 | ||
| 10 | import { useState, useEffect, useCallback } from "react" | 10 | import { 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' |
| 12 | import { loadCannbayConfig, cannbayRemoteInput, isDefaultCannbayRemote, DEFAULT_CANNBAY_ADDRESS } from '@/lib/cannbay-config' | 12 | import { loadCannbayConfig, cannbayRemoteInput, isDefaultCannbayRemote, DEFAULT_CANNBAY_ADDRESS } from '@/lib/cannbay-config' |
| 13 | import { | 13 | import { |
| 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 | : sessions | 474 | : 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_ADDRESS | 549 | : 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 | - CANNBay | 628 | + {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 | ||
| 11 | import { useEffect, useRef, useState } from 'react' | 11 | import { useEffect, useRef, useState } from 'react' |
| 12 | import Link from 'next/link' | 12 | import 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' |
| 14 | import { isProxyVersion } from '@/lib/shared/session-format' | 14 | import { isProxyVersion } from '@/lib/shared/session-format' |
| 15 | import { useRouter } from 'next/navigation'; | 15 | import { useRouter } from 'next/navigation'; |
| 16 | import { | 16 | import { |
| @@ -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 | ||
| 15 | import { useEffect, useState } from 'react' | 16 | import { useEffect, useState } from 'react' |
| 16 | import { loadCannbayConfig, cannbayRemoteInput, isDefaultCannbayRemote } from '@/lib/cannbay-config' | 17 | import { loadCannbayConfig, cannbayRemoteInput, isDefaultCannbayRemote } from '@/lib/cannbay-config' |
| 18 | +import { BAY_NAME } from '@/lib/branding' | ||
| 17 | import { Button } from '@/components/ui/button' | 19 | import { Button } from '@/components/ui/button' |
| 18 | import { Checkbox } from '@/components/ui/checkbox' | 20 | import { Checkbox } from '@/components/ui/checkbox' |
| 19 | import { | 21 | import { |
| @@ -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 and | 10 | // 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?: string | 23 | token?: string |
| 24 | branch?: string | 24 | branch?: string |
| 25 | directory?: string | 25 | directory?: string |
| 26 | + /** LFS 模式(数据仓须启用 LFS):.jsonl.gz 走 LFS 指针出仓,容量脱离 git 仓配额 */ | ||
| 27 | + lfs?: boolean | ||
| 26 | } | 28 | } |
| 27 | 29 | ||
| 28 | export interface CannbayConfig extends CannbayRemoteInput { | 30 | export interface CannbayConfig extends CannbayRemoteInput { |
| @@ -13,7 +13,7 @@ import fs from 'node:fs'; | |||
| 13 | import path from 'node:path'; | 13 | import path from 'node:path'; |
| 14 | import os from 'node:os'; | 14 | import os from 'node:os'; |
| 15 | import zlib from 'node:zlib'; | 15 | import 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'; |
| 17 | import { readCcSessionMeta } from '../ingest/proxy-classify'; | 17 | import { readCcSessionMeta } from '../ingest/proxy-classify'; |
| 18 | import { isProxyVersion } from '../shared/session-format'; | 18 | import { isProxyVersion } from '../shared/session-format'; |
| 19 | import { defaultCannbay2Remote } from './remote'; | 19 | import { defaultCannbay2Remote } from './remote'; |
| @@ -22,11 +22,15 @@ import { exportSessionToClaudeJsonl, xCannbay } from './export'; | |||
| 22 | import { importSession } from '@/lib/ingest/data-service'; | 22 | import { importSession } from '@/lib/ingest/data-service'; |
| 23 | import type { PrismaClient } from '@prisma/client'; | 23 | import 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 | ||
| 31 | export class GovernanceError extends Error { | 35 | export 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 | // 供导入完成态显示类型徽标 —— 与本地导入路径的响应形状对齐。 |
| 46 | async function readSessionType(prisma: PrismaLike, taskId: string): Promise<{ framework: string | null; version: string | null }> { | 55 | async 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),管线数据齐全。 |
| 175 | async function stageSessionFiles(session: SessionRow, prisma: PrismaLike): Promise<{ stagingDir: string; sid: string }> { | 184 | async 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-file | 10 | +// 列表 = 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 仓。 |
| 14 | import { execSync, spawnSync } from 'node:child_process'; | 16 | import { execSync, spawnSync } from 'node:child_process'; |
| 15 | import { randomUUID } from 'node:crypto'; | 17 | import { randomUUID } from 'node:crypto'; |
| 16 | import fs from 'node:fs'; | 18 | import fs from 'node:fs'; |
| 19 | +import os from 'node:os'; | ||
| 17 | import path from 'node:path'; | 20 | import path from 'node:path'; |
| 18 | import { defaultCannbay2Remote, escapeRegExp, normalizeDirPrefix, stripUrlCreds } from './remote'; | 21 | import { defaultCannbay2Remote, escapeRegExp, normalizeDirPrefix, stripUrlCreds } from './remote'; |
| 19 | import type { Cannbay2Remote } from './remote'; | 22 | import type { Cannbay2Remote } from './remote'; |
| @@ -35,8 +38,11 @@ export function cannbay2CacheDir(remote?: Cannbay2Remote): string { | |||
| 35 | // required=false → 即便 filter 配置异常也放行不炸 | 38 | // required=false → 即便 filter 配置异常也放行不炸 |
| 36 | const LFS_NEUTRALIZE = '-c filter.lfs.process= -c filter.lfs.smudge=cat -c filter.lfs.clean=cat -c filter.lfs.required=false'; | 39 | const 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 —— 这里要求拿回完整批量输出 buffer | 321 | // 走 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 读取 → 全部会话文件夹及提交元数据。 */ |
| 317 | export function listSessions(remote: Cannbay2Remote = defaultCannbay2Remote()): Cannbay2SessionEntry[] { | 355 | export 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()): | |||
| 387 | export function listRemoteDirs(remote: Cannbay2Remote = defaultCannbay2Remote(), prefix = ''): string[] { | 428 | export 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
![]() ![]() | |||
| 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.json | 621 | + 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 | - ? rel | 626 | + // 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'; | |||
| 17 | export const CANNBAY2_PULL_URL = process.env.CANNBAY2_REMOTE_URL | 17 | export 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)。 | ||
| 20 | const CANNBAY2_PUSH_URL_ENCODED = 'aHR0cHM6Ly9kdWFuYmluZ3poZTpvU1I1aTF3WXZ5dlZpUmdMbXdna3NDeG5AYXRvbWdpdC5jb20vZHVhbmJpbmd6aGUvY2FubmJheTMuZ2l0'; | 23 | const CANNBAY2_PUSH_URL_ENCODED = 'aHR0cHM6Ly9kdWFuYmluZ3poZTpvU1I1aTF3WXZ5dlZpUmdMbXdna3NDeG5AYXRvbWdpdC5jb20vZHVhbmJpbmd6aGUvY2FubmJheTMuZ2l0'; |
| 21 | export const CANNBAY2_PUSH_URL = process.env.CANNBAY2_PUSH_URL | 24 | export 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(); |
| 23 | export const CANNBAY2_BRANCH = process.env.CANNBAY2_BRANCH ?? 'main'; | 26 | export 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 | + | ||
| 25 | export interface Cannbay2Remote { | 51 | export 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 | ||
| 39 | const FORBIDDEN_ADDRESS_CHARS = /["'`$;&|\n\r]/; | 70 | const 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 | ||
| 13 | import { promises as fs } from 'node:fs'; | 13 | import { 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" |
| 10 | export const VERSION_DISPLAY = `v${VERSION}` | 10 | export 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"); fi | 97 | if [ "$UPDATE" = true ]; then PROXY_INSTALL_ARGS+=("-u"); fi |
| 98 | - if ! bash "$PROXY_INSTALL" "${PROXY_INSTALL_ARGS[@]}"; then | 98 | + 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 | fi | 105 | fi |
| 101 | fi | 106 | fi |
| 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 | +# 启动都误判"源码已变"触发无谓全量重建。 | ||
| 306 | NEXT_CMD="dev" | 314 | NEXT_CMD="dev" |
| 307 | if [ "$PROD" = true ] && [ "$CLI" != true ]; then | 315 | if [ "$PROD" = true ] && [ "$CLI" != true ]; then |
| 308 | NEED_BUILD=true | 316 | NEED_BUILD=true |
| 309 | if [ "$FRESH" != true ] && [ -f ".next/BUILD_ID" ]; then | 317 | 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" ]; then | 319 | if [ -z "$STALE" ]; then |
| 312 | NEED_BUILD=false | 320 | 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 | fi | 325 | fi |
| 318 | if [ "$NEED_BUILD" = true ]; then | 326 | 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 | fi | 337 | fi |
| 322 | NEXT_CMD="start" | 338 | NEXT_CMD="start" |
| 323 | fi | 339 | fi |
| @@ -21,13 +21,16 @@ import { createRoot, type Root } from 'react-dom/client' | |||
| 21 | import { act } from 'react' | 21 | import { act } from 'react' |
| 22 | import fs from 'node:fs' | 22 | import fs from 'node:fs' |
| 23 | import path from 'node:path' | 23 | import path from 'node:path' |
| 24 | +import { BAY_NAME } from '@/lib/branding' | ||
| 24 | 25 | ||
| 25 | vi.mock('next/navigation', () => ({ | 26 | vi.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') | ||
| 31 | const TASK_ID = 'ses_066d0d8aaffegJAFUjUGyxKm8z' | 34 | const TASK_ID = 'ses_066d0d8aaffegJAFUjUGyxKm8z' |
| 32 | 35 | ||
| 33 | function readJson(name: string): unknown { | 36 | function 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 HTMLElement | 131 | 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`) | |||
| 57 | fs.writeFileSync(path.join(SEED, 'others', POISON_SID, 'subagents', 'sub-poison.jsonl.gz'), POINTER_BLOB); | 57 | fs.writeFileSync(path.join(SEED, 'others', POISON_SID, 'subagents', 'sub-poison.jsonl.gz'), POINTER_BLOB); |
| 58 | git('git add .', SEED); | 58 | git('git add .', SEED); |
| 59 | git('git -c user.name=seed -c user.email=seed@t commit -q -m init', SEED); | 59 | git('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 | ||
| 62 | process.env.CANNBAY2_REMOTE_URL = BARE; | 65 | process.env.CANNBAY2_REMOTE_URL = BARE; |
| 63 | process.env.CANNBAY2_PUSH_URL = BARE; | 66 | process.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 | ||
| 38 | function makeRemote(cacheDir: string): Cannbay2Remote { | 38 | function 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 | ||
| 42 | beforeAll(() => { | 42 | beforeAll(() => { |
| @@ -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 迁移、错误消息脱敏。 |
| 12 | import { describe, it, expect, vi, afterEach } from 'vitest'; | 12 | import { 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'; | ||
| 13 | import path from 'node:path'; | 16 | import path from 'node:path'; |
| 14 | 17 | ||
| 15 | process.env.CANNBAY2_REMOTE_URL = '/tmp/unit-env-remote.git'; | 18 | process.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 共享仓 → atomgit | 165 | // 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 | + | ||
| 190 | describe('cannbay-config:迁移与请求体裁剪', () => { | 258 | describe('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" | |||
| 24 | import path from "node:path" | 24 | import path from "node:path" |
| 25 | import { fileURLToPath } from "node:url" | 25 | import { fileURLToPath } from "node:url" |
| 26 | import { VERSION } from "../../src/lib/version" | 26 | import { VERSION } from "../../src/lib/version" |
| 27 | +import { BRAND_NAME } from "../../src/lib/branding" | ||
| 27 | 28 | ||
| 28 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) | 29 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) |
| 29 | const PKG_DIR = path.resolve(__dirname, "../..") | 30 | const 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' | |||
| 18 | import { act } from 'react' | 18 | import { act } from 'react' |
| 19 | import { SessionList } from '@/components/SessionList' | 19 | import { SessionList } from '@/components/SessionList' |
| 20 | import { LocalFileImport } from '@/components/LocalFileImport' | 20 | import { LocalFileImport } from '@/components/LocalFileImport' |
| 21 | +import { BAY_NAME } from '@/lib/branding' | ||
| 21 | 22 | ||
| 22 | vi.mock('next/navigation', () => ({ | 23 | vi.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 HTMLButtonElement | 96 | + 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 HTMLSelectElement | 145 | 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 | // 范式选择步 → 点 others | 177 | // 范式选择步 → 点 others |
| 175 | await act(async () => { click(findButtonByText('others')!) }) | 178 | await act(async () => { click(findButtonByText('others')!) }) |


规则表与泄漏哨兵都只覆盖小写 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)。