diff --git a/doc/storage/design.md b/doc/storage/design.md index cf7984d9..698846cc 100644 --- a/doc/storage/design.md +++ b/doc/storage/design.md @@ -93,6 +93,10 @@ LanceDB fragment 是一次或多次写入形成的数据片段,不等于一行 ## 4. 文件资源与目录布局 +### 测试索引计算缓存 + +测试可显式启用持久解析/向量缓存;解析按真实源内容、影响锚点的名称和解析/切分指纹寻址,向量按嵌入文本序列、模型资产及执行指纹寻址。缓存不包含 workspace/document ID、绝对临时路径、数据库或 job 状态,绑定和索引写入始终在当前隔离存储完成。依赖指纹变化、快照结构/向量形状或完整性不匹配时重新计算,原子发布避免半成品。测试调度、预热与强制冷跑规则见 [测试规范](../test.md#调度与缓存)。 + ### 共享用户 Skill 资产 `userData/skills/{name}/` 保存用户导入或手动创建的标准 Skill 文件夹,包含 `SKILL.md` 与可选的 `scripts/`、`references/`、`assets/` 及其他普通文件。导入复制整个所选目录,不引用源路径,也不扫描平台账号或用户主目录。原始 YAML(包括未知字段)和资源字节保持不变。共享资产不是伙伴运行缓存,删除伙伴或清理 runtime 不删除它。 diff --git a/doc/test-plan/ai-test.md b/doc/test-plan/ai-test.md index 1f745622..9385b4c2 100644 --- a/doc/test-plan/ai-test.md +++ b/doc/test-plan/ai-test.md @@ -1150,11 +1150,11 @@ Unit `docx-diff` 补重复内容匹配、跨 run 字符、Unicode 分段、大 #### `human-user-skill-live` -- 测试类型:backend / live;suite `human`,group `live`。 -- What:真实模型按名称发现、加载并使用说明与相对资源,跨 run 恢复。 -- Why:专项验证说明型 Skill 的实际效果与生产接入。 -- How:真实 SQLite、UserSkillService、文件与生产 AiRunService;将周报规范及独有业务规则资源复制到隔离目录并启用,使用真实模型处理自然语言业务事实,连续执行两轮。准备输入不透露工具名或资源内答案,不使用模拟依赖。 -- Oracle:任务完成;`load_skill` 使用 `skillNames`;实际主模型请求包含正文和资源,结构化产物符合独有规则,第二轮恢复相同有效说明;记录真实 usage,缓存供应商指标仍由缓存专项验收。 +- 测试类型:backend / live;suite `human`,group `live`;不需要知识库预热。 +- What:真实客户周报与风险行动清单的多资料交付,两个互补 Skill 的选择、资源规则落地、冲突处理、跨 run 续做及规范更新。 +- Why:评估 Skill 对真实交付效果的作用,完成态和文件存在不能替代内容质量。 +- How:真实 SQLite、Skill 服务、生产 AiRunService 与模型;复用对外口径、排期、财务跟进、法务、交付合规、上周周报和内部备注。启用客户周报、风险行动两个互补 Skill 及一个无关会议纪要 Skill;规范携带相对资源,源目录导入后删除。用户只描述业务交付,不提供内部工具名、Skill 名称、规范或评审答案。第一轮产出客户周报 Markdown 和行动清单 CSV;第二轮加入新事实并更新团队规范,要求保留原版并生成更新稿。评审标准保留在测试 Oracle,绝不进入模型输入。 +- Oracle:自动守住真实按名称加载、正文及资源进入模型、交付可解析、源资料不变、两个 run 完成及最新规范恢复。执行后审阅 raw LLM I/O、工具日志及真实产物,按 10 分制逐项写入本次运行结果:事实与冲突处理 3 分,规范和资源落实 2 分,风险/职责/行动 2 分,引用依据 1 分,交付可用性 1 分,续做与规范更新 1 分;每项附证据与理由,严格超过 8 分验收。缺失核心交付、擅改源资料或关键事实造假不可通过;不在测试代码中生成固定质量分或自动分数门槛。记录真实 usage、耗时与工具调用,供应商缓存由缓存专项验收。 ## 5. 覆盖边界 diff --git a/doc/test-plan/knowledge-test.md b/doc/test-plan/knowledge-test.md index f18ad501..26dbcccd 100644 --- a/doc/test-plan/knowledge-test.md +++ b/doc/test-plan/knowledge-test.md @@ -116,6 +116,14 @@ - How:向真实 Store 准备已完成、执行中、待处理和旧失败条目;控制器记住各条目原状态后接收取消,检查取消标志、AbortSignal 与真实回退结果。另以预置 cancelled job 调用生产 KB 工具检查不触发索引拦截,不将它视作完整索引取消过程。 - Oracle:取消标志与 signal 生效;已完成保持 `indexed`,未完成回到 `needs_index` 或原 `failed` 并保留原错误,对齐 `SET-WORKSPACE-004-AC14` 及 `SET-WORKSPACE-004-AC16` 的条目回退部分;网页回到原 `needs_index / index_failed`,已完成保留,补充 `SET-WEB-004-AC12/AC13`;预置 cancelled job 不是 running,查询不触发索引拦截,补充 `SET-WORKSPACE-004-AC9`、`SET-WEB-004-AC8`。当前批后停止及顺序 helper 的取消契约由 `tests/case/unit/workspace/files/workspace-index-worker-contract.test.ts` 补充 `SET-WORKSPACE-004-AC15/AC16`;成功 / 失败终态不改为取消的纯函数契约由 `tests/case/unit/workspace/files/workspace-index-job-progress.test.ts` 补充 `SET-WORKSPACE-004-AC17`。这些 Unit 不能代替完整索引取消、任务收口和 UI 确认竞态。 +#### `workspace-knowledge-cache-reuse` + +- 测试类型:backend;suite `foundation`。 +- What:真实解析和嵌入缓存跨隔离工作区复用;修改一份资料只重算必要内容,重命名更新锚点。 +- Why:避免无关代码或临时路径变化引起重复资料向量化,同时守住索引内容与当前输入一致。 +- How:生产解析器、嵌入 executor、缓存与真实 store;先冷跑小目录,再在新工作区复用;修改一份文件并更改文件名后重新索引。通过真实 index result 与日志核对命中和重算,不模拟 executor。 +- Oracle:第二次解析和向量全部命中;仅内容/名称变化的条目发生必要 miss;返回当前 workspace/document 绑定与名称,无过期源路径;源文件保护。Unit 补充依赖指纹、并发/取消与死亡进程锁回收、损坏缓存及安装包身份;索引 UI 用例可用 `--fresh-knowledge-cache` 验证冷跑。 + ## 5. 覆盖边界 - foundation 登记功能场景,不评价召回优劣;Settings E2E 与本域 backend 分别提供 UI 闭环和底层线程证据。 diff --git a/doc/test-plan/settings-test.md b/doc/test-plan/settings-test.md index d0838719..147b01a4 100644 --- a/doc/test-plan/settings-test.md +++ b/doc/test-plan/settings-test.md @@ -109,7 +109,7 @@ Settings 顶层 suite 对齐 PRD 模块: - 测试类型:`e2e` - What:点击“更新索引”后,文件状态会刷新为已索引并同步更新摘要。 -- Why:“索引已经可用”的核心用户反馈链路。全仓唯一的向量 warmup,预热共享 fixture 的 split/vector 缓存。 +- Why:“索引已经可用”的核心用户反馈链路。作为独立 UI 回归执行,声明 `knowledge: cached`;缓存与按需预热遵循 [测试规范](../test.md)。 - How:复制共享 workspace 输入的隔离副本,通过 Settings UI 添加为文件地址并点击更新索引,读取刷新后的文件树和全部支持文件状态。共享 fixture 含 `.doc`、`docx`、`pdf`、`md`、`txt`、`xlsx`、`xlsm` 等全部受支持类型,其中旧版 DOC 使用政府网站发布的中文原件 `长江干线(江苏段)大型船舶通航安全风险分级管控办法(初稿).doc`,excel 只以元数据方式索引进库。不切换、不断言索引速度。点击后等待进度块出现,再等到目标总数算出后读取进度文案。 - Oracle:更新索引过程中出现持久化进度条和进度条下方的当前处理文件文案,「检查更新」可用,「更新索引」文案变为「取消」且可点,对齐 `SET-WORKSPACE-004-AC3`、`SET-WORKSPACE-004-AC12`;未知总数时进度条保持活动状态,目标数确定后按已处理目标数 / 目标总数计算比例,每个目标(包括失败目标)只计一次,进度块标题右侧不显示百分比或 `已处理/总数`;运行期间目录读取次数为 0,终态只刷新一次且不因逐文件完成而闪烁或折叠,对齐 `SET-WORKSPACE-004-AC7`;共享 fixture 中全部支持文件真实进入处理范围,对齐 `SET-WORKSPACE-004-AC1`;更新后所有支持文件收敛为 `indexed / failed`,摘要和节点状态同步刷新,对齐 `SET-WORKSPACE-004-AC2`。 @@ -117,7 +117,7 @@ Settings 顶层 suite 对齐 PRD 模块: - 测试类型:`e2e / exclusive` - What:独立小目录上,默认慢速真实更新索引一次,再补文件切高速再更新索引一次,比较两档嵌入进程的操作系统线程证据。 -- Why:档位是否生效,看的是更新索引时电脑上嵌入进程的线程数是否按 intraOp 增加,不是进程名里的数字,也不是共享 warmup fixture。 +- Why:档位是否生效,看的是更新索引时电脑上嵌入进程的线程数是否按 intraOp 增加,不是进程名里的数字,也不是共享索引 fixture。 - How:通过原生目录选择添加空临时目录;每次更新索引前写入 ≥50KB / 400 段待索引 txt 并检查更新,确认可索引数 ≥ 1 且「更新索引」可点。点击检查更新或更新索引前,先将指针移出工具栏,让上一按钮的悬浮提示自然关闭,不使用强制点击或移除遮罩。点击后以慢/高速变为 disabled 为同步点(允许进度块可见作为并列信号,10–30s,失败 dump actionable / 按钮 / embedding / job);不要把进度条可见当作唯一同步点,也不用 360s 去等它。锁定后再数嵌入进程线程。嵌入 runtime PID 由主进程只读 IPC 提供。线程数分别用 `ps -M` 与 `Get-Process.Threads.Count`。 - Oracle:默认慢速后可切换高速,对齐 `SET-WORKSPACE-009-AC1`;更新索引进行中禁用慢/高速,终态后恢复,对齐 `SET-WORKSPACE-009-AC4`;两次更新索引拉起不同嵌入进程,线程增量及 Windows 零增量时的稳态差按 [Knowledge 线程预算用例](knowledge-test.md#index-speed-thread-budget) 判定;两个 txt 均索引完成。 diff --git a/doc/test-plan/workbench-test.md b/doc/test-plan/workbench-test.md index ba658f50..f5f3d5ce 100644 --- a/doc/test-plan/workbench-test.md +++ b/doc/test-plan/workbench-test.md @@ -15,7 +15,7 @@ - Workbench UI E2E 测试通过脚本在 case 运行时把 `tests/fixtures/workspace` 复制到临时目录;需要文件资料时通过 Settings UI 添加文件地址、检查更新和更新索引。前置是创建伙伴;文件地址按场景添加,不作为通用发送门禁。 - Workbench 正式 e2e 不允许直接调用 `workspacePrepare`、直接写模型配置、fake model、fake search、fake docx、fake AI event、fake attachment pick 或固定 document id。现有用例的执行缺口见第 6 节。 -- Workbench e2e 的 workspace 文件索引允许在测试环境通过 `NCURATOR_WORKSPACE_KNOWLEDGE_CACHE=1` 启用真实 split / vector 缓存;缓存只按文件内容 hash、扩展名、splitter 参数和 embedding model 复用计算结果,不替代 workspace 文件记录、document id、搜索结果或 UI 状态。Source E2E 直接共享内容缓存并按 case 隔离统计;Source 与正式包 E2E 在多 case 时都先执行 `warmup` 预热共享 fixture,正式包 E2E 再把缓存种子复制给隔离 case。 +- Workbench e2e 按 `knowledge: none / cached / cold` 声明索引需求,默认 none。需要真实索引时复用生产 split/vector 计算缓存并隔离本用例统计;不自动追加 UI 预热用例,不复用存储和 UI 状态。缓存身份、按需预热与清理规则遵循 [测试规范](../test.md)。 - Workbench 模型选择 helper 优先按预设 option 的稳定调用名 `data-testid`(`AI_LIVE_MODEL`)点选;未命中再按展示名;仍未命中则进入「自定义模型」按名称搜索。 - Workbench UI E2E 测试需要真实索引完成信号和 UI 可见状态作为等待条件;IPC observer 和 job 查询只能作为辅助证据,不能用来造业务状态。 - `file-preview-actions` 当前按生产真实索引能力覆盖 `pdf/docx/md/txt/xlsx/xlsm/xltx/xltm`:连续打开的文件要分别保留为资源 Tab,并能切回旧 Tab;Excel 普通预览还要覆盖只读、切 Sheet 和拖列宽后预览仍可用。`workspace-file-explorer-open-refresh` 覆盖列表打开/焦点刷新、右键行内新建和确认移到回收站,以及删除后禁止自动保存重建。`file-preview-external-change-auto-refresh` 另行覆盖所有实时预览的统一磁盘监听:以普通文本、DOCX、PPTX、XLSX 为代表验证,文件地址内的文件和 `userData/partners/{id}/runtime/assets` 中的真实附件复用同一预览刷新链路;审核 Diff 快照不监听;仅文件内容变化才提示“已自动刷新”,打开外部应用或仅元数据变化不提示。WPS 刷新分两条:`wps-sync-dispatch-on-write-and-revert` 覆盖馆长对 docx / xlsx / pptx 的 `/status` dispatch,不打开 WPS;真窗口刷新 `wps-office-reload-on-write-and-revert`(路径 `result/wps-office/`,`pnpm test:workbench:wps-office`)覆盖已授权 WPS 窗口,因本机授权弹窗可能卡住自动化。加载项与 `publish.xml` / `authaddin` 合并由 unit 覆盖;用户行为见 `WB-WPS-001`。 diff --git a/doc/test.md b/doc/test.md index bedec1d0..2b23c4b7 100644 --- a/doc/test.md +++ b/doc/test.md @@ -155,10 +155,13 @@ runner 不负责: #### 调度与缓存 -- E2E 通过 `execution` 声明调度方式:默认 `normal`;真实 AI / 服务调用使用 `live`;需要桌面焦点、鼠标或原生对话框的用例使用 `exclusive`;向量冷启动预热用例使用 `warmup`。 -- 一次执行超过一个 E2E 文件时,runner 自动纳入唯一的 `warmup` 并先串行执行,不受 domain、suite、case 或测试名称筛选影响。预热共享 fixture 的索引供后续 case 复用;预热失败则停止后续执行。 +- E2E 的 `execution` 只控制调度:默认 `normal`;真实 AI / 服务调用为 `live`;需要桌面焦点或原生对话框为 `exclusive`。原有 Settings 索引 UI case 独立执行,不再作为批量测试的自动前置。 +- case 通过 `knowledge: 'none' | 'cached' | 'cold'` 声明知识库需求,默认 `none`;仅需直接读取文件的 Skill / 文档任务不预热知识库。需要真实索引的 case 声明 `cached`,模型安装/损坏、冷启动、线程预算与嵌入专项声明 `cold`。 +- 预热随已选 case 的真实索引请求按文件执行,统一检查并补齐缺失的解析/向量结果,无需另一份 fixture 清单。命中时直接复用,跨命令、跨日期有效;相同输入互斥并在锁内复查,原子发布已完成条目,失败后只补缺失部分。 +- 解析缓存由文件内容、扩展名、影响解析的名称信息及解析/切分依赖指纹决定;向量缓存由实际嵌入文本序列、模型资产和嵌入依赖指纹决定。构建时自动收集生产入口依赖及相关库版本;Main/Preload 整体构建哈希与日期仅为运行证据,不参与缓存有效性判断。 +- 各 case 保留独立工作区、SQLite、LanceDB、job 和权限;只复用纯计算结果,查询向量仍按真实检索计算。日志分别记录解析/向量命中、实际计算量与失效原因;模型初始化、索引写入和资料向量化分别取证。 - backend 普通测试并发上限为 6;E2E 总并发上限为 3,其中 `live` 上限为 3;`exclusive` 用例串行独占。case 失败后不自动重跑,继续收集其余 case 的结果。 -- 源码 E2E 按构建指纹共享 split/vector 内容缓存,各 case 隔离统计文件;正式包 E2E 将预热缓存复制到各 case 的隔离目录。缓存重建与调试复用通过下述参数控制。 +- `--fresh-knowledge-cache` 强制本次冷缓存运行;缓存完整性与指纹始终校验,不提供绕过有效性校验的复用方式。source/package 使用各自实际构建携带的索引指纹;没有指纹的旧正式包禁用计算缓存,安全冷跑。 #### 常用命令与参数 @@ -200,8 +203,7 @@ runner 支持的过滤参数: - `--runs` - `--target`,取值为 `source` 或 `package`,默认 `source`。 - `--bundle`,正式包 E2E 必填,指向已构建候选包的 `release-bundle-*.json`。 -- `--fresh-knowledge-cache`,删除本次构建对应的知识库缓存,强制从零验证完整索引。 -- `--reuse-knowledge-cache`,人工调试时跨构建复用同一类缓存;不能和 `--fresh-knowledge-cache` 同时使用。 +- `--fresh-knowledge-cache`,清理当前平台的索引计算缓存,强制从零验证完整索引。 - `--showcase`,执行 showcase 采集类别。 - `--result`,showcase/发布流程用的结果输出路径。 - `--help`(或 `-h`),打印 runner 用法后直接退出;传入未识别的参数会立即报错并退出(退出码 2),防止未知参数被静默忽略而误触发全量执行。 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 0ed8cb05..cd8ec825 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,18 +1,33 @@ import { resolve } from 'path' -import { defineConfig } from 'electron-vite' +import { defineConfig, type UserConfig } from 'electron-vite' import tailwindcss from '@tailwindcss/vite' import react from '@vitejs/plugin-react' import { loadEnv } from 'vite' +import { createKnowledgeCacheIdentity } from './scripts/build/runtime/knowledge-cache-identity.mjs' -export default defineConfig(({ mode }) => { +export default defineConfig(async ({ mode }): Promise => { + const knowledgeIdentity = await createKnowledgeCacheIdentity() const env = loadEnv(mode, process.cwd(), '') const desktopAppSecret = env.NCURATOR_APP_SECRET_DESKTOP || process.env.NCURATOR_APP_SECRET_DESKTOP || '' return { main: { + plugins: [ + { + name: 'knowledge-cache-identity', + generateBundle() { + this.emitFile({ + type: 'asset', + fileName: 'knowledge-cache-identity.json', + source: JSON.stringify(knowledgeIdentity) + }) + } + } + ], define: { - 'process.env.NCURATOR_APP_SECRET_DESKTOP': JSON.stringify(desktopAppSecret) + 'process.env.NCURATOR_APP_SECRET_DESKTOP': JSON.stringify(desktopAppSecret), + __NCURATOR_KNOWLEDGE_IDENTITY__: JSON.stringify(knowledgeIdentity) }, build: { minify: 'esbuild', diff --git a/scripts/build/runtime/knowledge-cache-identity.mjs b/scripts/build/runtime/knowledge-cache-identity.mjs new file mode 100644 index 00000000..40bffde0 --- /dev/null +++ b/scripts/build/runtime/knowledge-cache-identity.mjs @@ -0,0 +1,88 @@ +import { createHash } from 'node:crypto' +import { readFile, realpath } from 'node:fs/promises' +import { createRequire } from 'node:module' +import path from 'node:path' + +const require = createRequire(import.meta.url) +const { build } = require( + require.resolve('esbuild', { + paths: [path.dirname(require.resolve('vite/package.json'))] + }) +) + +// esbuild's graph follows runtime imports and erases type-only imports. Changes to +// unrelated application modules therefore do not invalidate computation results. +export const fingerprintKnowledgePipeline = async (root, entryPoints) => { + const graph = await build({ + absWorkingDir: root, + entryPoints, + bundle: true, + platform: 'node', + packages: 'external', + write: false, + metafile: true, + outdir: 'knowledge-identity', + logLevel: 'silent' + }) + const records = new Map() + const seenPackages = new Set() + const visitPackage = async (specifier, importer) => { + if (specifier.startsWith('node:') || specifier === 'electron') return + const resolver = createRequire(importer) + let resolved + try { + resolved = resolver.resolve(specifier) + } catch { + return + } + if (!path.isAbsolute(resolved)) return + let directory = path.dirname(await realpath(resolved)) + let manifest + while (directory !== path.dirname(directory)) { + try { + manifest = JSON.parse(await readFile(path.join(directory, 'package.json'), 'utf8')) + break + } catch { + directory = path.dirname(directory) + } + } + if (!manifest || seenPackages.has(directory)) return + seenPackages.add(directory) + const installedReference = + directory.split(`${path.sep}.pnpm${path.sep}`)[1]?.split(path.sep)[0] || + `${manifest.name}@${manifest.version}` + records.set(`package:${installedReference}`, JSON.stringify(manifest)) + for (const dependency of Object.keys({ + ...manifest.dependencies, + ...manifest.optionalDependencies + }).sort()) { + await visitPackage(dependency, path.join(directory, 'package.json')) + } + } + for (const [file, metadata] of Object.entries(graph.metafile.inputs).sort(([a], [b]) => + a.localeCompare(b) + )) { + const absolutePath = path.resolve(root, file) + records.set(file, await readFile(absolutePath)) + for (const dependency of metadata.imports.filter((item) => item.external)) { + await visitPackage(dependency.path, absolutePath) + } + } + const hash = createHash('sha256') + for (const [name, content] of [...records].sort(([a], [b]) => a.localeCompare(b))) { + hash.update(name).update('\0').update(content).update('\0') + } + hash.update(`${process.platform}/${process.arch}`) + return hash.digest('hex') +} + +export const createKnowledgeCacheIdentity = async (root = process.cwd()) => ({ + split: await fingerprintKnowledgePipeline(root, [ + 'src/main/services/workspace/worker/workspacePrepareWorker.ts', + 'src/main/services/pdf/worker/pdfWorker.ts' + ]), + embedding: await fingerprintKnowledgePipeline(root, [ + 'src/main/services/vector/runtime/TransformersEmbeddingExecutor.ts', + 'src/main/services/vector/EmbeddingService.ts' + ]) +}) diff --git a/scripts/test/package-app.mjs b/scripts/test/package-app.mjs index 5fd17923..a0cfe241 100644 --- a/scripts/test/package-app.mjs +++ b/scripts/test/package-app.mjs @@ -3,6 +3,7 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSy import { mkdtemp } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' +import { extractFile, uncache } from '@electron/asar' import { resolveBundleInstaller } from '../release/artifact-bundle.mjs' import { PACKAGE_PRODUCT_NAME, @@ -267,3 +268,28 @@ export const cleanupPackagedE2EApplication = (session, { log = () => {} } = {}) if (!installRoot) return { removed: false } return cleanupPackagedE2EInstall({ installRoot, log }) } + +// Cache identity must come from the installed artifact, never the current source tree. +export const readPackagedKnowledgeCacheIdentity = ( + installedAppPath, + platform = process.platform +) => { + const resources = + platform === 'darwin' + ? path.join(installedAppPath, 'Contents', 'Resources') + : path.join(installedAppPath, 'resources') + uncache(path.join(resources, 'app.asar')) + try { + const identity = JSON.parse( + extractFile( + path.join(resources, 'app.asar'), + 'out/main/knowledge-cache-identity.json' + ).toString('utf8') + ) + return /^[a-f0-9]{64}$/.test(identity.split) && /^[a-f0-9]{64}$/.test(identity.embedding) + ? identity + : null + } catch { + return null + } +} diff --git a/scripts/test/validate-test-architecture.mjs b/scripts/test/validate-test-architecture.mjs index 9f6da707..d657ebcc 100644 --- a/scripts/test/validate-test-architecture.mjs +++ b/scripts/test/validate-test-architecture.mjs @@ -101,7 +101,6 @@ const planCases = async (planName) => { const failures = [] const summaries = [] -const warmupFiles = [] for (const [domain, rule] of Object.entries(DOMAIN_RULES)) { const domainRoot = path.join(CASE_ROOT, domain) @@ -121,15 +120,18 @@ for (const [domain, rule] of Object.entries(DOMAIN_RULES)) { if (rule.type === 'e2e' && !isE2E) failures.push(`${relativePath}: ${domain} 只允许 E2E 测试`) if (rule.type === 'backend' && isE2E) failures.push(`${relativePath}: ${domain} 只允许 backend 测试`) + for (const match of source.matchAll(/\bknowledge\s*:\s*['"]([^'"]+)['"]/gu)) { + if (!['none', 'cached', 'cold'].includes(match[1])) + failures.push(`${relativePath}: unsupported knowledge policy ${match[1]}`) + } if (isE2E) { const executionValues = [...source.matchAll(/\bexecution\s*:\s*['"]([^'"]+)['"]/gu)].map( (match) => match[1] ) for (const execution of executionValues) { - if (!['normal', 'live', 'exclusive', 'warmup'].includes(execution)) { + if (!['normal', 'live', 'exclusive'].includes(execution)) { failures.push(`${relativePath}: unsupported E2E execution class ${execution}`) } - if (execution === 'warmup') warmupFiles.push(relativePath) } if (new Set(executionValues).size > 1) { failures.push(`${relativePath}: one E2E file cannot mix execution classes`) @@ -336,12 +338,6 @@ for (const [domain, rule] of Object.entries(DOMAIN_RULES)) { } } -if (warmupFiles.length !== 1) { - failures.push( - `E2E must declare exactly one vector warm-up file; found ${String(warmupFiles.length)}: ${warmupFiles.join(', ') || '(none)'}` - ) -} - if (failures.length > 0) { throw new Error(`测试架构校验失败:\n${failures.map((item) => `- ${item}`).join('\n')}`) } diff --git a/scripts/test/workflow.mjs b/scripts/test/workflow.mjs index f036b3c0..6759bae1 100644 --- a/scripts/test/workflow.mjs +++ b/scripts/test/workflow.mjs @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { readFileSync, realpathSync } from 'node:fs' -import { cp, mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' +import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' import { createRequire } from 'node:module' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -12,7 +12,11 @@ import { runE2EGates } from './e2e-results.mjs' import { spawn, spawnSync } from 'node:child_process' -import { preparePackagedE2EApplication } from './package-app.mjs' +import { + preparePackagedE2EApplication, + readPackagedKnowledgeCacheIdentity +} from './package-app.mjs' +import { createKnowledgeCacheIdentity } from '../build/runtime/knowledge-cache-identity.mjs' const ROOT = process.cwd() const require = createRequire(import.meta.url) @@ -41,17 +45,10 @@ const LIVE_WORKERS = 3 const RUNNER_DEADLINE_MS = 60 * 60 * 1000 const DEADLINE_EXIT_CODE = 124 const STALE_E2E_TEMP_MAX_AGE_MS = 24 * 60 * 60 * 1000 -const E2E_EXECUTION_CLASSES = new Set(['normal', 'live', 'exclusive', 'warmup']) +const E2E_EXECUTION_CLASSES = new Set(['normal', 'live', 'exclusive']) const USE_WINDOWS_SHELL = process.platform === 'win32' const activeAsyncChildren = new Set() let stoppingActiveChildren = false -const VECTOR_WARMUP_TEST_FILE = path.join( - TEST_CASES_ROOT, - 'settings', - 'workspace', - 'files', - 'workspace-prepare-refresh.e2e.test.ts' -) const KNOWLEDGE_CACHE_ROOT = path.join(TESTS_ROOT, '.cache', 'workspace-knowledge') const EMPTY_KNOWLEDGE_CACHE_STATS = { splitCacheHits: 0, @@ -114,7 +111,6 @@ const parseArgs = (argv) => { bundles: [], results: [], freshKnowledgeCache: false, - reuseKnowledgeCache: false, help: false, unknownArgs: [], invalidArgs: [] @@ -168,10 +164,6 @@ const parseArgs = (argv) => { parsed.freshKnowledgeCache = true continue } - if (current === '--reuse-knowledge-cache') { - parsed.reuseKnowledgeCache = true - continue - } if (current === '--help' || current === '-h') { parsed.help = true continue @@ -231,18 +223,16 @@ const readKnowledgeCacheStats = async (cachePath) => { } const prepareKnowledgeCache = async (input) => { - const cachePath = input.reuse - ? path.join(KNOWLEDGE_CACHE_ROOT, 'debug-reuse', input.namespace) - : path.join(KNOWLEDGE_CACHE_ROOT, input.namespace, input.buildFingerprint) + const cachePath = path.join(KNOWLEDGE_CACHE_ROOT, 'v3', `${process.platform}-${process.arch}`) if (input.fresh) await rm(cachePath, { recursive: true, force: true }) await mkdir(cachePath, { recursive: true }) return { - knowledgeCacheEnabled: true, + knowledgeCacheEnabled: input.enabled !== false, knowledgeCachePath: cachePath, knowledgeCacheNamespace: input.namespace, buildFingerprint: input.buildFingerprint, env: { - NCURATOR_WORKSPACE_KNOWLEDGE_CACHE: '1', + NCURATOR_WORKSPACE_KNOWLEDGE_CACHE: input.enabled === false ? '0' : '1', NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_PATH: cachePath, NCURATOR_E2E_KNOWLEDGE_CACHE_NAMESPACE: input.namespace, NCURATOR_E2E_BUILD_FINGERPRINT: input.buildFingerprint @@ -272,9 +262,9 @@ const toCaseId = (filePath) => const readE2EExecutionClass = async (filePath) => { const source = await readFile(filePath, 'utf8') - const matches = [ - ...source.matchAll(/\bexecution\s*:\s*['"](normal|live|exclusive|warmup)['"]/gu) - ].map((match) => match[1]) + const matches = [...source.matchAll(/\bexecution\s*:\s*['"](normal|live|exclusive)['"]/gu)].map( + (match) => match[1] + ) const declared = [...new Set(matches)] if (declared.length > 1) { throw new Error(`${path.relative(ROOT, filePath)} declares multiple E2E execution classes`) @@ -291,21 +281,6 @@ const buildKnowledgeCacheCaseKey = (testFile) => { return `${toCaseId(testFile)}-${createHash('sha256').update(relativePath).digest('hex').slice(0, 12)}` } -const buildCaseKnowledgeCacheEnv = async (env, testFile) => { - const basePath = String(env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_PATH || '').trim() - if (!basePath) return env - const caseKey = buildKnowledgeCacheCaseKey(testFile) - const casePath = path.join(basePath, 'cases', caseKey) - await mkdir(casePath, { recursive: true }) - return { - ...env, - NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_PATH: casePath, - NCURATOR_E2E_KNOWLEDGE_CACHE_NAMESPACE: `${String( - env.NCURATOR_E2E_KNOWLEDGE_CACHE_NAMESPACE || 'e2e' - )}/${caseKey}` - } -} - const buildDirectKnowledgeCacheEnv = async (env, testFile) => { const cachePath = String(env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_PATH || '').trim() if (!cachePath) return env @@ -321,17 +296,6 @@ const buildDirectKnowledgeCacheEnv = async (env, testFile) => { } } -const seedCaseKnowledgeCache = async (caseEnv, seedPath) => { - const casePath = String(caseEnv.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_PATH || '').trim() - if (!seedPath || !casePath || path.resolve(seedPath) === path.resolve(casePath)) return - await cp(seedPath, casePath, { - recursive: true, - force: false, - errorOnExist: false, - filter: (source) => path.basename(source) !== 'stats.json' - }) -} - const fileContainsCaseId = async (filePath, caseId) => { if (toCaseId(filePath) === caseId) return true const source = await readFile(filePath, 'utf8') @@ -676,13 +640,9 @@ const runE2ETestFilesConcurrently = async ( targets, testNamePattern, deadlineAt, - addKnowledgeWarmup = true, configPath = 'vitest.e2e.config.mts', { gateCaseIds = [], previousResults = [] } = {} ) => { - if (addKnowledgeWarmup && !testFiles.includes(VECTOR_WARMUP_TEST_FILE) && testFiles.length > 1) { - testFiles = [...testFiles, VECTOR_WARMUP_TEST_FILE] - } const fileTasks = await Promise.all( testFiles.map(async (testFile) => ({ testFile, @@ -694,17 +654,14 @@ const runE2ETestFilesConcurrently = async ( fileTasks.map((task) => ({ ...task, targetId: target.id, - env: target.env, - cacheMode: target.cacheMode + env: target.env })) ) .map((task, index) => ({ ...task, index })) const runnableTasks = tasks - const warmupTasks = runnableTasks.filter((task) => task.execution === 'warmup') const gateTasks = runnableTasks.filter((task) => gateCaseIds.includes(toCaseId(task.testFile))) const concurrentTasks = runnableTasks.filter( - (task) => - task.execution !== 'exclusive' && task.execution !== 'warmup' && !gateTasks.includes(task) + (task) => task.execution !== 'exclusive' && !gateTasks.includes(task) ) const exclusiveTasks = runnableTasks.filter( (task) => task.execution === 'exclusive' && !gateTasks.includes(task) @@ -714,7 +671,6 @@ const runE2ETestFilesConcurrently = async ( let liveActive = 0 let completed = 0 const activeByTarget = new Map() - const warmupCacheByTarget = new Map() const recordDeadline = (task) => { exitCode = DEADLINE_EXIT_CODE @@ -733,13 +689,13 @@ const runE2ETestFilesConcurrently = async ( else results.push(taskResult) } - const runTask = async (task, seedPath = '') => { + const runTask = async (task) => { const relativeFile = path.relative(ROOT, task.testFile) completed += 1 process.stdout.write( `[test-workflow] e2e ${String(completed)}/${String(tasks.length)} [${task.targetId}/${task.execution}] ${relativeFile}\n` ) - const pattern = task.execution === 'warmup' ? '' : testNamePattern + const pattern = testNamePattern const caseIds = readTestCaseIds(task.testFile, await readFile(task.testFile, 'utf8')).filter( (id) => !pattern || new RegExp(pattern, 'u').test(id) ) @@ -747,7 +703,6 @@ const runE2ETestFilesConcurrently = async ( (test) => test.file === relativeFile && test.targetId === task.targetId ) if ( - task.execution !== 'warmup' && previous && passedPackageTest(previous, task.env.NCURATOR_E2E_ARTIFACT_SHA256) && caseIds.length > 0 && @@ -758,16 +713,12 @@ const runE2ETestFilesConcurrently = async ( return { code: 0 } } const startedAt = Date.now() - const caseEnv = - task.cacheMode === 'direct' - ? await buildDirectKnowledgeCacheEnv(task.env, task.testFile) - : await buildCaseKnowledgeCacheEnv(task.env, task.testFile) - if (task.cacheMode === 'warmup') await seedCaseKnowledgeCache(caseEnv, seedPath) + const caseEnv = await buildDirectKnowledgeCacheEnv(task.env, task.testFile) let code = await runVitest({ configPath, testFiles: [task.testFile], env: caseEnv, - testNamePattern: task.execution === 'warmup' ? '' : testNamePattern, + testNamePattern, workers: 1, deadlineAt }) @@ -805,41 +756,7 @@ const runE2ETestFilesConcurrently = async ( } } - for (let index = 0; index < warmupTasks.length; index += 1) { - const task = warmupTasks[index] - if (Date.now() >= deadlineAt) { - for (const remaining of warmupTasks.slice(index)) recordDeadline(remaining) - return { - exitCode, - results: results.sort((left, right) => left.index - right.index) - } - } - process.stdout.write( - `[test-workflow] vector warm-up ${String(index + 1)}/${String(warmupTasks.length)} must pass before parallel E2E starts\n` - ) - const outcome = await runTask(task) - if (outcome.code !== 0) { - exitCode = outcome.code - process.stderr.write( - '[test-workflow] vector warm-up failed; parallel E2E will not start on a cold cache\n' - ) - return { - exitCode, - results: results.sort((left, right) => left.index - right.index) - } - } - warmupCacheByTarget.set(task.targetId, outcome.cachePath) - } - - if (warmupCacheByTarget.size > 0) { - process.stdout.write( - `[test-workflow] vector warm-up passed; isolated E2E caches will be seeded for ${String(warmupCacheByTarget.size)} target(s)\n` - ) - } - - const gateFailure = await runE2EGates(gateTasks, (task) => - runTask(task, warmupCacheByTarget.get(task.targetId) || '') - ) + const gateFailure = await runE2EGates(gateTasks, (task) => runTask(task)) if (gateFailure) { process.stderr.write( `[release-cache-gate] ${toCaseId(gateFailure.task.testFile)} failed. Stop and report the cause; user decision is required before any correction, retry or further release work.\n` @@ -864,7 +781,7 @@ const runE2ETestFilesConcurrently = async ( if (task.execution === 'live') liveActive += 1 activeByTarget.set(task.targetId, Number(activeByTarget.get(task.targetId) || 0) + 1) let running - running = runTask(task, warmupCacheByTarget.get(task.targetId)).finally(() => { + running = runTask(task).finally(() => { if (task.execution === 'live') liveActive -= 1 activeByTarget.set( task.targetId, @@ -886,7 +803,7 @@ const runE2ETestFilesConcurrently = async ( for (const remaining of exclusiveTasks.slice(index)) recordDeadline(remaining) break } - await runTask(task, warmupCacheByTarget.get(task.targetId)) + await runTask(task) } exitCode = results.reduce((code, result) => { @@ -1009,11 +926,23 @@ const runTypeWorkflow = async (input) => { const knowledgeCache = await prepareKnowledgeCache({ namespace: 'source', buildFingerprint, - fresh: input.freshKnowledgeCache, - reuse: input.reuseKnowledgeCache + fresh: input.freshKnowledgeCache }) runtimeEnv = { ...runtimeEnv, ...knowledgeCache.env } - e2eTargets = [{ id: 'source', env: runtimeEnv, cacheMode: 'direct' }] + e2eTargets = [{ id: 'source', env: runtimeEnv }] + } + + if (input.type === 'backend' && input.domain !== 'unit') { + const cache = await prepareKnowledgeCache({ + namespace: 'backend', + buildFingerprint: '', + fresh: input.freshKnowledgeCache + }) + runtimeEnv = { + ...runtimeEnv, + ...cache.env, + NCURATOR_KNOWLEDGE_IDENTITY: JSON.stringify(await createKnowledgeCacheIdentity(ROOT)) + } } let exitCode = 0 @@ -1061,7 +990,6 @@ const runTypeWorkflow = async (input) => { e2eTargets, testNamePattern, input.deadlineAt, - !input.showcase, input.showcase ? 'vitest.showcase.config.mts' : 'vitest.e2e.config.mts', { gateCaseIds: input.gateCaseIds || [], previousResults: input.previousResults || [] } ) @@ -1093,7 +1021,6 @@ const USAGE = `NCurator 测试 runner --bundle 正式包 bundle,可重复传入 --result bundle 结果路径,可重复传入 --fresh-knowledge-cache 删除知识库缓存强制重建 - --reuse-knowledge-cache 复用知识库缓存(调试用) -h, --help 显示本帮助并退出 注意: @@ -1173,9 +1100,6 @@ export const runTestWorkflow = async (argv, options = {}) => { if (args.target !== 'package' && args.results.length > 0) { throw new Error('--result 只能与 --target package 一起使用') } - if (args.freshKnowledgeCache && args.reuseKnowledgeCache) { - throw new Error('--fresh-knowledge-cache 和 --reuse-knowledge-cache 不能同时使用') - } const architectureValidationCode = validateTestArchitecture() if (architectureValidationCode !== 0) process.exit(architectureValidationCode) @@ -1203,7 +1127,6 @@ export const runTestWorkflow = async (argv, options = {}) => { const target = { id: session.releasePlatform, env: { ...session.env }, - cacheMode: 'warmup', session, knowledgeCache: { knowledgeCacheEnabled: false, @@ -1216,8 +1139,8 @@ export const runTestWorkflow = async (argv, options = {}) => { const knowledgeCache = await prepareKnowledgeCache({ namespace: `package/${session.releasePlatform}`, buildFingerprint: session.artifactSha256, - fresh: args.freshKnowledgeCache, - reuse: args.reuseKnowledgeCache + enabled: Boolean(readPackagedKnowledgeCacheIdentity(session.installedAppPath)), + fresh: args.freshKnowledgeCache }) target.env = { ...session.env, ...knowledgeCache.env } target.knowledgeCache = knowledgeCache @@ -1237,11 +1160,7 @@ export const runTestWorkflow = async (argv, options = {}) => { const result = await runTypeWorkflow({ ...args, type: 'e2e', - e2eTargets: packageTargets.map(({ id, env, cacheMode }) => ({ - id, - env, - cacheMode - })), + e2eTargets: packageTargets.map(({ id, env }) => ({ id, env })), deadlineAt }) exitCode = result.exitCode diff --git a/src/main/services/vector/runtime/modelAssetIdentity.ts b/src/main/services/vector/runtime/modelAssetIdentity.ts new file mode 100644 index 00000000..f5c22f84 --- /dev/null +++ b/src/main/services/vector/runtime/modelAssetIdentity.ts @@ -0,0 +1,63 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { readFile, readdir, stat } from 'node:fs/promises' +import path from 'node:path' +import { getUserDataPath } from '../../../utils/userDataPath' +import { writeJsonAtomically } from '../../../utils/writeJsonAtomically' + +const digests = new Map>() + +/** File metadata only avoids rehashing unchanged assets; cache identity uses bytes. */ +export const getModelAssetIdentity = async (modelName: string): Promise => { + const modelsRoot = process.env.NCURATOR_MODELS_PATH || path.join(getUserDataPath(), 'models') + const root = path.join(modelsRoot, modelName) + const entries: Array<{ name: string; absolute: string; signature: string }> = [] + const walk = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name) + if (entry.isDirectory()) await walk(absolute) + else if (entry.isFile() && !entry.name.startsWith('.')) { + const info = await stat(absolute, { bigint: true }) + entries.push({ + name: path.relative(root, absolute), + absolute, + signature: `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}` + }) + } + } + } + await walk(root) + if (entries.length === 0) throw new Error(`No model assets found: ${modelName}`) + const result = createHash('sha256') + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const key = `${entry.absolute}:${entry.signature}` + let digest = digests.get(key) + if (!digest) { + digest = (async () => { + const cacheRoot = process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_PATH + const identity = createHash('sha256').update(key).digest('hex') + const file = cacheRoot ? path.join(cacheRoot, 'model-digests', `${identity}.json`) : null + if (file) { + const stored = await readFile(file, 'utf8') + .then((raw) => JSON.parse(raw) as { identity: string; digest: string }) + .catch(() => null) + if (stored?.identity === identity && /^[a-f0-9]{64}$/.test(stored.digest)) + return stored.digest + } + const hash = createHash('sha256') + for await (const bytes of createReadStream(entry.absolute)) hash.update(bytes) + const value = hash.digest('hex') + if (file) await writeJsonAtomically(file, { identity, digest: value }) + return value + })() + digests.set(key, digest) + void digest.catch(() => digests.delete(key)) + } + result + .update(entry.name) + .update('\0') + .update(await digest) + .update('\0') + } + return result.digest('hex') +} diff --git a/src/main/services/workspace/WorkspaceKnowledgeCache.ts b/src/main/services/workspace/WorkspaceKnowledgeCache.ts index efac88bd..127a5f35 100644 --- a/src/main/services/workspace/WorkspaceKnowledgeCache.ts +++ b/src/main/services/workspace/WorkspaceKnowledgeCache.ts @@ -1,246 +1,238 @@ import { createHash, randomUUID } from 'node:crypto' -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { mkdir, readFile, readdir, rmdir, stat, unlink, writeFile } from 'node:fs/promises' import path from 'node:path' -import { SPLITTER_BIG_CHUNK_SETTING, SPLITTER_MINI_CHUNK_SIZE } from '../../utils/constant' -import { getUserDataPath } from '../../utils/paths' +import { setTimeout } from 'node:timers/promises' +import { getUserDataPath } from '../../utils/userDataPath' +import { writeJsonAtomically } from '../../utils/writeJsonAtomically' +import { getModelAssetIdentity } from '../vector/runtime/modelAssetIdentity' +import { IndexPrepareCancelledError } from './WorkspaceIndexPrepareController' import type { WorkspaceKnowledgeCache, + WorkspaceKnowledgeSplitKey, + WorkspaceKnowledgeVectorKey, WorkspaceKnowledgeSplitSnapshot, WorkspaceKnowledgeVectorSnapshot } from './types' -const WORKSPACE_KNOWLEDGE_CACHE_SCHEMA_VERSION = 2 - -type WorkspaceKnowledgeCacheStats = { +declare const __NCURATOR_KNOWLEDGE_IDENTITY__: { split: string; embedding: string } | undefined +const SCHEMA = 3 +const digest = (value: object): string => + createHash('sha256').update(JSON.stringify(value)).digest('hex') +type PipelineIdentity = { split: string; embedding: string } +type CacheStats = { splitCacheHits: number splitCacheMisses: number vectorCacheHits: number vectorCacheMisses: number } - -const EMPTY_CACHE_STATS: WorkspaceKnowledgeCacheStats = { +const emptyStats = (): CacheStats => ({ splitCacheHits: 0, splitCacheMisses: 0, vectorCacheHits: 0, vectorCacheMisses: 0 -} +}) -type PersistedWorkspaceSplitCache = { - schemaVersion: number - splitter: { - big: typeof SPLITTER_BIG_CHUNK_SETTING - mini: typeof SPLITTER_MINI_CHUNK_SIZE - } - snapshot: WorkspaceKnowledgeSplitSnapshot -} - -type PersistedWorkspaceVectorCache = { - schemaVersion: number - splitCacheKey: string - snapshot: WorkspaceKnowledgeVectorSnapshot -} - -const isWorkspaceKnowledgeCacheEnabled = (): boolean => - String(process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE || '').trim() === '1' - -const resolveWorkspaceKnowledgeCacheRoot = (): string => { - const explicitPath = String(process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_PATH || '').trim() - if (explicitPath) return path.resolve(explicitPath) - return path.join(getUserDataPath(), 'workspace-knowledge-cache') -} - -const resolveWorkspaceKnowledgeCacheStatsPath = (cacheRoot: string): string => { - const explicitPath = String( - process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_STATS_PATH || '' - ).trim() - return explicitPath ? path.resolve(explicitPath) : path.join(cacheRoot, 'stats.json') -} - -const buildSplitCacheKey = (input: { contentHash: string; fileExt: string }): string => - createHash('sha256') - .update( - JSON.stringify({ - schemaVersion: WORKSPACE_KNOWLEDGE_CACHE_SCHEMA_VERSION, - contentHash: input.contentHash, - fileExt: input.fileExt, - splitter: { - big: SPLITTER_BIG_CHUNK_SETTING, - mini: SPLITTER_MINI_CHUNK_SIZE - } - }) - ) - .digest('hex') - -const buildVectorCacheKey = (input: { splitCacheKey: string; modelName: string }): string => - createHash('sha256') - .update( - JSON.stringify({ - schemaVersion: WORKSPACE_KNOWLEDGE_CACHE_SCHEMA_VERSION, - splitCacheKey: input.splitCacheKey, - modelName: input.modelName - }) - ) - .digest('hex') - -const writeCacheSnapshot = async (filePath: string, payload: string): Promise => { - await mkdir(path.dirname(filePath), { recursive: true }) - const tempPath = `${filePath}.${String(process.pid)}.${randomUUID()}.tmp` - await writeFile(tempPath, payload, 'utf8') - try { - await rename(tempPath, filePath) - } catch (error) { - await rm(tempPath, { force: true }) +// Locks are process-owned: a killed warm-up is reclaimed without expiring a slow, +// healthy embedding operation. Every caller rechecks the entry inside the lock. +const locked = async ( + directory: string, + action: () => Promise, + signal?: AbortSignal +): Promise => { + await mkdir(path.dirname(directory), { recursive: true }) + const owner = `${process.pid}-${randomUUID()}` + const started = Date.now() + for (;;) { + if (signal?.aborted) throw new IndexPrepareCancelledError() try { - await readFile(filePath, 'utf8') - } catch { - throw error + await mkdir(directory) + await writeFile(path.join(directory, owner), '') + break + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + const owners = await readdir(directory).catch(() => []) + for (const previous of owners) { + const pid = Number(previous.split('-')[0]) + if (!Number.isSafeInteger(pid) || pid <= 0) continue + try { + process.kill(pid, 0) + } catch (probeError) { + if ((probeError as NodeJS.ErrnoException).code !== 'ESRCH') continue + // Only the contender that removed this specific dead owner can reclaim + // its directory. Others cannot remove a newly acquired lock. + try { + await unlink(path.join(directory, previous)) + await rmdir(directory) + } catch { + /* reclaimed by another contender */ + } + } + } + if (owners.length === 0) { + const info = await stat(directory).catch(() => null) + if (info && Date.now() - info.mtimeMs > 5_000) await rmdir(directory).catch(() => {}) + } + if (Date.now() - started > 900_000) + throw new Error(`Timed out waiting for knowledge computation lock: ${directory}`) + await setTimeout(100) } } + try { + return await action() + } finally { + await unlink(path.join(directory, owner)) + await rmdir(directory) + } } class PersistentWorkspaceKnowledgeCache implements WorkspaceKnowledgeCache { private readonly statsPath: string - private stats: WorkspaceKnowledgeCacheStats = { ...EMPTY_CACHE_STATS } - private statsInitialization: Promise | null = null - private statsWriteQueue: Promise = Promise.resolve() - private statsWriteSequence = 0 - - public constructor(private readonly cacheRoot: string) { - this.statsPath = resolveWorkspaceKnowledgeCacheStatsPath(cacheRoot) + public constructor( + private readonly root: string, + private readonly pipeline: PipelineIdentity + ) { + this.statsPath = + process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_STATS_PATH || path.join(root, 'stats.json') } - - private async initializeStats(): Promise { - if (!this.statsInitialization) { - this.statsInitialization = (async () => { - try { - const parsed = JSON.parse( - await readFile(this.statsPath, 'utf8') - ) as Partial - this.stats = { - splitCacheHits: Number(parsed.splitCacheHits || 0), - splitCacheMisses: Number(parsed.splitCacheMisses || 0), - vectorCacheHits: Number(parsed.vectorCacheHits || 0), - vectorCacheMisses: Number(parsed.vectorCacheMisses || 0) - } - } catch { - this.stats = { ...EMPTY_CACHE_STATS } - } - })() - } - await this.statsInitialization - } - - private async recordStat(key: keyof WorkspaceKnowledgeCacheStats): Promise { - await this.initializeStats() - this.stats[key] += 1 - this.statsWriteSequence += 1 - const tempPath = `${this.statsPath}.${String(process.pid)}.${String(this.statsWriteSequence)}.tmp` - this.statsWriteQueue = this.statsWriteQueue.then(async () => { - await mkdir(path.dirname(this.statsPath), { recursive: true }) - await writeFile(tempPath, `${JSON.stringify(this.stats, null, 2)}\n`, 'utf8') - await rename(tempPath, this.statsPath) - }) - await this.statsWriteQueue - } - - public async loadSplitSnapshot(input: { - contentHash: string - fileExt: string - }): Promise { - const cacheFilePath = path.join(this.cacheRoot, 'splits', `${buildSplitCacheKey(input)}.json`) - try { - const file = JSON.parse(await readFile(cacheFilePath, 'utf8')) as PersistedWorkspaceSplitCache - if (file.schemaVersion !== WORKSPACE_KNOWLEDGE_CACHE_SCHEMA_VERSION) { - await this.recordStat('splitCacheMisses') - return null + private async record(key: keyof CacheStats): Promise { + await locked(`${this.statsPath}.lock`, async () => { + const previous = await readFile(this.statsPath, 'utf8') + .then((raw) => JSON.parse(raw) as Partial | null) + .catch(() => null) + const stats = emptyStats() + for (const field of Object.keys(stats) as Array) { + const value = Number(previous?.[field]) + stats[field] = Number.isFinite(value) && value >= 0 ? value : 0 } - await this.recordStat('splitCacheHits') - return file.snapshot - } catch { - await this.recordStat('splitCacheMisses') - return null - } - } - - public async saveSplitSnapshot(snapshot: WorkspaceKnowledgeSplitSnapshot): Promise { - const cacheFilePath = path.join( - this.cacheRoot, - 'splits', - `${buildSplitCacheKey({ - contentHash: snapshot.contentHash, - fileExt: snapshot.fileExt - })}.json` - ) - const payload: PersistedWorkspaceSplitCache = { - schemaVersion: WORKSPACE_KNOWLEDGE_CACHE_SCHEMA_VERSION, - splitter: { - big: SPLITTER_BIG_CHUNK_SETTING, - mini: SPLITTER_MINI_CHUNK_SIZE - }, - snapshot - } - await writeCacheSnapshot(cacheFilePath, JSON.stringify(payload)) - } - - public async loadVectorSnapshot(input: { - contentHash: string - fileExt: string - modelName: string - }): Promise { - const splitCacheKey = buildSplitCacheKey({ - contentHash: input.contentHash, - fileExt: input.fileExt + stats[key] += 1 + await writeJsonAtomically(this.statsPath, stats) }) - const cacheFilePath = path.join( - this.cacheRoot, - 'vectors', - `${buildVectorCacheKey({ - splitCacheKey, - modelName: input.modelName - })}.json` + } + public withLock(key: string, action: () => Promise, signal?: AbortSignal): Promise { + return locked( + path.join(this.root, 'locks', `${digest({ key, pipeline: this.pipeline })}.lock`), + action, + signal ) + } + private splitKey(input: WorkspaceKnowledgeSplitKey): string { + return digest({ schema: SCHEMA, pipeline: this.pipeline.split, ...input }) + } + private async vectorKey( + input: Pick + ): Promise { + return digest({ + schema: SCHEMA, + pipeline: this.pipeline.embedding, + modelName: input.modelName, + modelAssets: await getModelAssetIdentity(input.modelName), + texts: input.texts + }) + } + private async load(kind: string, key: string): Promise { try { const file = JSON.parse( - await readFile(cacheFilePath, 'utf8') - ) as PersistedWorkspaceVectorCache - if ( - file.schemaVersion !== WORKSPACE_KNOWLEDGE_CACHE_SCHEMA_VERSION || - file.splitCacheKey !== splitCacheKey - ) { - await this.recordStat('vectorCacheMisses') - return null + await readFile(path.join(this.root, kind, `${key}.json`), 'utf8') + ) as { + key: string + checksum: string + payload: T } - await this.recordStat('vectorCacheHits') - return file.snapshot - } catch { - await this.recordStat('vectorCacheMisses') + if (file.key === key && digest(file.payload) === file.checksum) return file.payload + console.info(`[knowledge-cache] ${kind} miss key=${key.slice(0, 12)} reason=integrity`) + return null + } catch (error) { + console.info( + `[knowledge-cache] ${kind} miss key=${key.slice(0, 12)} reason=${(error as NodeJS.ErrnoException).code === 'ENOENT' ? 'not-found' : 'unreadable'}` + ) return null } } - - public async saveVectorSnapshot(snapshot: WorkspaceKnowledgeVectorSnapshot): Promise { - const splitCacheKey = buildSplitCacheKey({ - contentHash: snapshot.contentHash, - fileExt: snapshot.fileExt + private async save(kind: string, key: string, payload: object): Promise { + await writeJsonAtomically(path.join(this.root, kind, `${key}.json`), { + key, + checksum: digest(payload), + payload }) - const cacheFilePath = path.join( - this.cacheRoot, - 'vectors', - `${buildVectorCacheKey({ - splitCacheKey, - modelName: snapshot.modelName - })}.json` + } + public async loadSplitSnapshot( + input: WorkspaceKnowledgeSplitKey + ): Promise { + const snapshot = await this.load( + 'splits', + this.splitKey(input) ) - const payload: PersistedWorkspaceVectorCache = { - schemaVersion: WORKSPACE_KNOWLEDGE_CACHE_SCHEMA_VERSION, - splitCacheKey, + const valid = + snapshot?.contentHash === input.contentHash && + snapshot.fileExt === input.fileExt && + Array.isArray(snapshot.chunks) && + snapshot.chunks.every( + (chunk) => typeof chunk.text === 'string' && Number.isFinite(chunk.page) + ) + await this.record(valid ? 'splitCacheHits' : 'splitCacheMisses') + return valid ? snapshot : null + } + public saveSplitSnapshot( + snapshot: WorkspaceKnowledgeSplitSnapshot, + sourceName: string + ): Promise { + return this.save( + 'splits', + this.splitKey({ contentHash: snapshot.contentHash, fileExt: snapshot.fileExt, sourceName }), snapshot - } - await writeCacheSnapshot(cacheFilePath, JSON.stringify(payload)) + ) + } + public async loadVectorSnapshot( + input: WorkspaceKnowledgeVectorKey + ): Promise { + const payload = await this.load<{ vectors: number[][] }>('vectors', await this.vectorKey(input)) + const vectors = payload?.vectors + const valid = + Array.isArray(vectors) && + vectors.length === input.texts.length && + vectors.every( + (vector) => + Array.isArray(vector) && + vector.length > 0 && + vector.length === vectors[0].length && + vector.every(Number.isFinite) + ) + await this.record(valid ? 'vectorCacheHits' : 'vectorCacheMisses') + return valid + ? { + contentHash: input.contentHash, + fileExt: input.fileExt, + modelName: input.modelName, + vectors + } + : null + } + public async saveVectorSnapshot( + snapshot: WorkspaceKnowledgeVectorSnapshot, + texts: string[] + ): Promise { + await this.save('vectors', await this.vectorKey({ modelName: snapshot.modelName, texts }), { + vectors: snapshot.vectors + }) } } -export const createWorkspaceKnowledgeCache = (): WorkspaceKnowledgeCache | null => { - if (!isWorkspaceKnowledgeCacheEnabled()) return null - return new PersistentWorkspaceKnowledgeCache(resolveWorkspaceKnowledgeCacheRoot()) +export const createWorkspaceKnowledgeCache = (options?: { + root: string + pipeline: PipelineIdentity +}): WorkspaceKnowledgeCache | null => { + if (!options && process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE !== '1') return null + const pipeline = + options?.pipeline ?? + (typeof __NCURATOR_KNOWLEDGE_IDENTITY__ !== 'undefined' + ? __NCURATOR_KNOWLEDGE_IDENTITY__ + : (JSON.parse(process.env.NCURATOR_KNOWLEDGE_IDENTITY || 'null') as PipelineIdentity | null)) + // Older packages have no baked identity; safely recompute instead of borrowing source identity. + if (!pipeline?.split || !pipeline.embedding) return null + return new PersistentWorkspaceKnowledgeCache( + options?.root || + process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_PATH || + path.join(getUserDataPath(), 'workspace-knowledge-cache'), + pipeline + ) } diff --git a/src/main/services/workspace/WorkspaceKnowledgePrepareService.ts b/src/main/services/workspace/WorkspaceKnowledgePrepareService.ts index acdc8931..2c27ef4d 100644 --- a/src/main/services/workspace/WorkspaceKnowledgePrepareService.ts +++ b/src/main/services/workspace/WorkspaceKnowledgePrepareService.ts @@ -73,6 +73,19 @@ export class WorkspaceKnowledgePrepareService { public async indexLocalFile( input: WorkspaceKnowledgeIndexInput + ): Promise { + this.throwIfAborted(input.signal) + if (!input.cache) return this.indexLocalFileUnlocked(input) + const contentHash = await this.computeContentHash(input.filePath) + return input.cache.withLock( + `file:${contentHash}:${path.basename(input.filePath)}`, + () => this.indexLocalFileUnlocked(input), + input.signal + ) + } + + private async indexLocalFileUnlocked( + input: WorkspaceKnowledgeIndexInput ): Promise { this.throwIfAborted(input.signal) if (!isSupportedLocalFilePath(input.filePath)) { @@ -89,13 +102,10 @@ export class WorkspaceKnowledgePrepareService { this.throwIfAborted(input.signal) const fileExt = path.extname(input.filePath).toLowerCase() const cachedSplit = input.cache - ? await input.cache.loadSplitSnapshot({ contentHash, fileExt }) - : null - const cachedVectors = input.cache - ? await input.cache.loadVectorSnapshot({ + ? await input.cache.loadSplitSnapshot({ contentHash, fileExt, - modelName: this.deps.embeddingService.getModelName() + sourceName: path.basename(input.filePath) }) : null const preparedSource = await this.prepareSource({ @@ -104,6 +114,21 @@ export class WorkspaceKnowledgePrepareService { cachedSplit }) this.throwIfAborted(input.signal) + if (input.cache && !this.isUsableCachedSplit(cachedSplit, preparedSource)) { + await input.cache.saveSplitSnapshot( + preparedSource.splitSnapshot, + path.basename(input.filePath) + ) + } + const cachedVectors = input.cache + ? await input.cache.loadVectorSnapshot({ + contentHash, + fileExt, + modelName: this.deps.embeddingService.getModelName(), + texts: preparedSource.splitSnapshot.chunks.map((chunk) => chunk.text) + }) + : null + this.throwIfAborted(input.signal) const indexedDocument = await this.buildPreparedDocument({ preparedSource, workspaceId: input.workspaceId, @@ -119,7 +144,6 @@ export class WorkspaceKnowledgePrepareService { await this.persistCacheSnapshots({ indexedDocument, cache: input.cache, - usedCachedSplit, usedCachedVectors }) this.throwIfAborted(input.signal) @@ -148,6 +172,18 @@ export class WorkspaceKnowledgePrepareService { public async indexWebText( input: WorkspaceKnowledgeWebInput + ): Promise { + this.throwIfAborted(input.signal) + if (!input.cache) return this.indexWebTextUnlocked(input) + return input.cache.withLock( + `web:${input.contentHash}:${input.documentName}:${input.sourceUrl}`, + () => this.indexWebTextUnlocked(input), + input.signal + ) + } + + private async indexWebTextUnlocked( + input: WorkspaceKnowledgeWebInput ): Promise { this.throwIfAborted(input.signal) await ensureEmbeddingReady({ embeddingService: this.deps.embeddingService }) @@ -159,13 +195,10 @@ export class WorkspaceKnowledgePrepareService { const contentHash = input.contentHash const fileExt = '.html' const cachedSplit = input.cache - ? await input.cache.loadSplitSnapshot({ contentHash, fileExt }) - : null - const cachedVectors = input.cache - ? await input.cache.loadVectorSnapshot({ + ? await input.cache.loadSplitSnapshot({ contentHash, fileExt, - modelName: this.deps.embeddingService.getModelName() + sourceName: `${input.documentName}\n${input.sourceUrl}` }) : null const preparedSource = await this.prepareSource({ @@ -177,6 +210,21 @@ export class WorkspaceKnowledgePrepareService { cachedSplit }) this.throwIfAborted(input.signal) + if (input.cache && !this.isUsableCachedSplit(cachedSplit, preparedSource)) { + await input.cache.saveSplitSnapshot( + preparedSource.splitSnapshot, + `${input.documentName}\n${input.sourceUrl}` + ) + } + const cachedVectors = input.cache + ? await input.cache.loadVectorSnapshot({ + contentHash, + fileExt, + modelName: this.deps.embeddingService.getModelName(), + texts: preparedSource.splitSnapshot.chunks.map((chunk) => chunk.text) + }) + : null + this.throwIfAborted(input.signal) const indexedDocument = await this.buildPreparedDocument({ preparedSource, workspaceId: input.workspaceId, @@ -192,7 +240,6 @@ export class WorkspaceKnowledgePrepareService { await this.persistCacheSnapshots({ indexedDocument, cache: input.cache, - usedCachedSplit, usedCachedVectors }) this.throwIfAborted(input.signal) @@ -296,15 +343,14 @@ export class WorkspaceKnowledgePrepareService { private async persistCacheSnapshots(input: { indexedDocument: WorkspaceKnowledgePreparedDocument cache?: WorkspaceKnowledgeIndexInput['cache'] - usedCachedSplit: boolean usedCachedVectors: boolean }): Promise { if (!input.cache) return - if (!input.usedCachedSplit) { - await input.cache.saveSplitSnapshot(input.indexedDocument.splitSnapshot) - } if (!input.usedCachedVectors) { - await input.cache.saveVectorSnapshot(input.indexedDocument.vectorSnapshot) + await input.cache.saveVectorSnapshot( + input.indexedDocument.vectorSnapshot, + input.indexedDocument.splitSnapshot.chunks.map((chunk) => chunk.text) + ) } } @@ -323,6 +369,11 @@ export class WorkspaceKnowledgePrepareService { signal?: AbortSignal }): Promise { const indexedDocument = input.indexedDocument + if (process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE === '1') { + console.info( + `[workspace-knowledge] ${input.documentName}: split=${input.usedCachedSplit ? 'hit' : 'computed'} vectors=${input.usedCachedVectors ? 'hit' : 'computed'} chunks=${indexedDocument.chunkCount}` + ) + } const now = Date.now() const newGenerationId = uuidv4() diff --git a/src/main/services/workspace/types.ts b/src/main/services/workspace/types.ts index 55e27f71..633cf789 100644 --- a/src/main/services/workspace/types.ts +++ b/src/main/services/workspace/types.ts @@ -46,18 +46,32 @@ export type WorkspaceKnowledgeChunkInput = { metadata: { page: number } } +export type WorkspaceKnowledgeSplitKey = { + contentHash: string + fileExt: string + sourceName: string +} + +export type WorkspaceKnowledgeVectorKey = { + contentHash: string + fileExt: string + modelName: string + texts: string[] +} + export type WorkspaceKnowledgeCache = { - loadSplitSnapshot: (input: { - contentHash: string - fileExt: string - }) => Promise - saveSplitSnapshot: (snapshot: WorkspaceKnowledgeSplitSnapshot) => Promise - loadVectorSnapshot: (input: { - contentHash: string - fileExt: string - modelName: string - }) => Promise - saveVectorSnapshot: (snapshot: WorkspaceKnowledgeVectorSnapshot) => Promise + withLock: (key: string, action: () => Promise, signal?: AbortSignal) => Promise + loadSplitSnapshot: ( + input: WorkspaceKnowledgeSplitKey + ) => Promise + saveSplitSnapshot: ( + snapshot: WorkspaceKnowledgeSplitSnapshot, + sourceName: string + ) => Promise + loadVectorSnapshot: ( + input: WorkspaceKnowledgeVectorKey + ) => Promise + saveVectorSnapshot: (snapshot: WorkspaceKnowledgeVectorSnapshot, texts: string[]) => Promise } /** 网页正文源:已抓好的文本,无本地文件路径 */ diff --git a/src/main/utils/writeJsonAtomically.ts b/src/main/utils/writeJsonAtomically.ts new file mode 100644 index 00000000..66a4b746 --- /dev/null +++ b/src/main/utils/writeJsonAtomically.ts @@ -0,0 +1,14 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, rename, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' + +export const writeJsonAtomically = async (file: string, payload: object): Promise => { + await mkdir(path.dirname(file), { recursive: true }) + const temporary = `${file}.${process.pid}.${randomUUID()}.tmp` + try { + await writeFile(temporary, JSON.stringify(payload), 'utf8') + await rename(temporary, file) + } finally { + await rm(temporary, { force: true }) + } +} diff --git a/tests/case/ai/human/live/human-kb-guided-docx-comparison-draft-live.test.ts b/tests/case/ai/human/live/human-kb-guided-docx-comparison-draft-live.test.ts index fc3c9bbd..20d454f2 100644 --- a/tests/case/ai/human/live/human-kb-guided-docx-comparison-draft-live.test.ts +++ b/tests/case/ai/human/live/human-kb-guided-docx-comparison-draft-live.test.ts @@ -44,6 +44,7 @@ describe('ai human live backend', () => { it('human-kb-guided-docx-comparison-draft-live', async () => { await runBackendCase({ caseId: 'human-kb-guided-docx-comparison-draft-live', + knowledge: 'cached', harnessMode: 'live', domain: 'ai', suite: 'human', diff --git a/tests/case/ai/human/live/human-user-skill-live.test.ts b/tests/case/ai/human/live/human-user-skill-live.test.ts index a17fe2c9..6ebd14cf 100644 --- a/tests/case/ai/human/live/human-user-skill-live.test.ts +++ b/tests/case/ai/human/live/human-user-skill-live.test.ts @@ -1,5 +1,7 @@ +import { createHash } from 'node:crypto' import fs from 'node:fs/promises' import path from 'node:path' +import ExcelJS from 'exceljs' import { describe, it } from 'vitest' import { UserSkillService } from '../../../../../src/main/services/skills/UserSkillService' import { runBackendCase } from '../../../../harness/backend/run-backend-case' @@ -7,13 +9,28 @@ import { loadLiveLlmConfigFromEnv } from '../../../../harness/backend/llmConfig' import { createAiServiceTestRunner } from '../../../../harness/backend/aiServiceHarness' const CASE_ID = 'human-user-skill-live' -type Report = { - ruleVersion: string - status: string - progress: string[] - risks: string[] - nextSteps: string[] +const SKILL_NAMES = ['customer-weekly', 'risk-actions', 'meeting-minutes'] +const OUTPUTS = [ + '交付/客户周报.md', + '交付/风险行动清单.csv', + '交付/客户周报-更新版.md', + '交付/风险行动清单-更新版.csv' +] +const UPDATED_RULE = + '本团队的新版清单还须在末尾增加「复核人」「验收证据」两列。复核人按本轮补充事实填写;验收证据写明实际收到的材料或仍需取得的确认,不编造已经验收。' +const readCsv = async (filename: string): Promise => { + const workbook = new ExcelJS.Workbook() + const sheet = await workbook.csv.readFile(filename) + const rows: string[][] = [] + sheet.eachRow((row) => { + const cells: string[] = [] + row.eachCell({ includeEmpty: true }, (cell) => cells.push(cell.text)) + rows.push(cells) + }) + return rows } +const hash = (bytes: Buffer): string => createHash('sha256').update(bytes).digest('hex') + describe('ai human user skill live', () => { it( CASE_ID, @@ -21,19 +38,24 @@ describe('ai human user skill live', () => { await runBackendCase({ caseId: CASE_ID, harnessMode: 'live', + knowledge: 'none', domain: 'ai', suite: 'human', groups: ['live'], - description: '真实 AI 按名称使用技能、相对资源和业务规则,第二轮恢复说明。', - fixtures: ['tests/fixtures/workspace/skills/weekly-report'], + description: + '多源客户周报与风险行动清单:选择互补技能、核对冲突与责任、两轮恢复并采用更新规范;质量依实际产物评分。', + fixtures: [ + 'tests/fixtures/workspace/工作', + ...SKILL_NAMES.map((name) => `tests/fixtures/skills/${name}`) + ], + outputFiles: OUTPUTS, runMode: 'single', runtimeModelName: loadLiveLlmConfigFromEnv().model_name, execute: async (runtime) => { - await runtime.seedLlmConfig(loadLiveLlmConfigFromEnv()) const partnerId = 'user-skill-live-partner' await runtime.partnerStore.savePartner({ id: partnerId, - name: '周报规范伙伴', + name: '客户交付', avatar_shape: 'circle', avatar_color: 'blue', created_at: Date.now(), @@ -46,94 +68,153 @@ describe('ai human user skill live', () => { runtime.storageService.userSkill, path.join(runtime.workspaceRoot, '.ncurator-test', 'skill-assets') ) - const source = path.join(runtime.workspaceRoot, 'skills', 'weekly-report') - const preview = service.prepareImport(source) - const skill = service.commitImport({ token: preview.token, replaceExisting: false }) - service.setEnabled(partnerId, [skill.id]) - await fs.rm(source, { recursive: true }) + const skills = SKILL_NAMES.map((name) => { + const preview = service.prepareImport(path.join(runtime.workspaceRoot, 'skills', name)) + return service.commitImport({ token: preview.token, replaceExisting: false }) + }) + service.setEnabled( + partnerId, + skills.map((skill) => skill.id) + ) + await fs.rm(path.join(runtime.workspaceRoot, 'skills'), { recursive: true }) + const sources = await Promise.all( + runtime.fixtureBindings + .filter((binding) => binding.workspacePath.includes(`${path.sep}工作${path.sep}`)) + .map(async (binding) => ({ + file: binding.workspacePath, + hash: hash(await fs.readFile(binding.workspacePath)) + })) + ) const first = await createAiServiceTestRunner(runtime, { partnerId, runId: `${runtime.runId}-first`, userSkillService: service, - userInput: `请按照已配置的周报归档规范整理本周进展,保存为 ${path.join(runtime.workspaceRoot, 'report-one.json')}。事实:完成客户访谈;接口联调因外部排期延后;下周继续联调并提交第一版方案。` + userInput: + '请基于工作目录里的资料,整理本周可供客户审阅的项目周报,同时给我一份内部跟进用的风险行动清单。参考上周外发稿的语气,核对资料里的不同说法,重点说明现在是否能收口、谁牵头、各方接下来做什么。周报存为交付/客户周报.md,清单存为交付/风险行动清单.csv。' }) const firstResult = await first.run() + const firstFiles = await Promise.all( + OUTPUTS.slice(0, 2).map((file) => fs.readFile(path.join(runtime.workspaceRoot, file))) + ) + const riskSkill = skills.find((skill) => skill.name === 'risk-actions')! + const document = service.getDocument(riskSkill.id) + service.update({ + id: riskSkill.id, + name: document.name, + description: document.description, + body: `${document.body}\n${UPDATED_RULE}\n` + }) const second = await createAiServiceTestRunner(runtime, { partnerId, runId: `${runtime.runId}-second`, userSkillService: service, - userInput: `继续沿用周报归档规范,另存为 ${path.join(runtime.workspaceRoot, 'report-two.json')}。事实:完成方案初稿;仍等待外部联调窗口;下周确认窗口。` + userInput: + '补充最新进展:Bob 已补齐付款材料,附件已提交复核;Alice 已确认周四15点复核窗口,最终合规签署还未完成。本轮由 Carol 复核清单,已收到的付款回执编号是 FIN-2406。请结合原资料和这次进展更新两份交付,采用团队最新规范,分别另存交付/客户周报-更新版.md和交付/风险行动清单-更新版.csv,保留上版。' }) const secondResult = await second.run() - const reportOne = JSON.parse( - await fs.readFile(path.join(runtime.workspaceRoot, 'report-one.json'), 'utf8') - ) as Report - const reportTwo = JSON.parse( - await fs.readFile(path.join(runtime.workspaceRoot, 'report-two.json'), 'utf8') - ) as Report return { firstResult, secondResult, - reportOne, - reportTwo, + firstFiles, + reports: await Promise.all( + [OUTPUTS[0], OUTPUTS[2]].map((file) => + fs.readFile(path.join(runtime.workspaceRoot, file), 'utf8') + ) + ), + csv: await Promise.all( + [OUTPUTS[1], OUTPUTS[3]].map((file) => + readCsv(path.join(runtime.workspaceRoot, file)) + ) + ), + originalsPreserved: ( + await Promise.all( + OUTPUTS.slice(0, 2).map( + async (file, index) => + hash(await fs.readFile(path.join(runtime.workspaceRoot, file))) === + hash(firstFiles[index]) + ) + ) + ).every(Boolean), + sourcesPreserved: ( + await Promise.all( + sources.map(async (source) => hash(await fs.readFile(source.file)) === source.hash) + ) + ).every(Boolean), events: [...first.events, ...second.events], firstIo: first.io, secondIo: second.io } }, assert: async (result, runtime) => { - const loadedByName = result.events.some( - (event) => - event.type === 'tool_call_started' && - event.toolName === 'load_skill' && - Array.isArray(event.input.skillNames) && - event.input.skillNames.includes('weekly-report') + const loadCalls = result.events.filter( + (event) => event.type === 'tool_call_started' && event.toolName === 'load_skill' + ) + const loadedNames = loadCalls.flatMap((event) => + event.type === 'tool_call_started' && Array.isArray(event.input.skillNames) + ? event.input.skillNames.map(String) + : [] + ) + const firstWire = JSON.stringify( + result.firstIo.map((record) => record.requestWire?.messages) + ) + const restoredSystem = JSON.stringify( + result.secondIo[0]?.requestWire?.messages.filter((message) => message.role === 'system') ) - const validReport = (report: Report) => - report.ruleVersion === 'NC-SKILL-REPORT-314' && - report.status === 'waiting-external' && - Array.isArray(report.progress) && - report.progress.length > 0 && - Array.isArray(report.risks) && - report.risks.length > 0 && - Array.isArray(report.nextSteps) && - report.nextSteps.length > 0 runtime.assertThat( - result.firstResult.status === 'completed' && - result.secondResult.status === 'completed' && - loadedByName && - validReport(result.reportOne) && - validReport(result.reportTwo) && - result.firstIo.some((record) => - record.requestWire?.messages.some( - (message) => - message.role === 'system' && String(message.content).includes('# 周报归档规范') - ) - ) && - result.firstIo.some((record) => record.user.includes('NC-SKILL-REPORT-314')) && - result.secondIo[0]?.requestWire?.messages.some( - (message) => - message.role === 'system' && String(message.content).includes('# 周报归档规范') - ) === true, + result.firstResult.status === 'completed' && result.secondResult.status === 'completed', { - name: 'user-skill-live-delivery', - message: '真实模型应按名称加载技能、读取资源,生成符合独有规则的两份产物并恢复说明。', - data: { - loadedByName, - firstStatus: result.firstResult.status, - secondStatus: result.secondResult.status, - reportOne: result.reportOne, - reportTwo: result.reportTwo, - usages: [...result.firstIo, ...result.secondIo].map((record) => ({ - runId: record.runId, - usage: record.usage ?? null - })) - } + name: 'two-round-production-completion', + message: '两轮均经生产 AI 入口完成。' } ) + runtime.assertThat( + ['customer-weekly', 'risk-actions'].every((name) => loadedNames.includes(name)) && + firstWire.includes('# 客户周报规范') && + firstWire.includes('# 风险行动清单规范') && + firstWire.includes('客户同步稿 / CW-2026') && + firstWire.includes('列顺序:事项') && + restoredSystem.includes('# 客户周报规范') && + restoredSystem.includes(UPDATED_RULE), + { + name: 'name-loading-resources-and-current-rules', + message: '按名称加载互补技能,资源真实进入模型;下一轮恢复状态并读取更新后的正文。', + data: { loadedNames } + } + ) + runtime.assertThat( + result.csv.every( + (rows) => + rows.length > 1 && + rows[0].length >= 8 && + rows.every((row) => row.length === rows[0].length) + ) && result.reports.every((report) => report.length > 100), + { + name: 'deliverables-readable', + message: '两版 Markdown 与标准 CSV 均可读取,清单各行列数一致。' + } + ) + runtime.assertThat(result.sourcesPreserved && result.originalsPreserved, { + name: 'source-and-first-delivery-preserved', + message: '业务原件和首版交付的内容哈希保持不变。' + }) + runtime.recordEvent({ + type: 'signal', + name: 'skill-quality-evidence', + status: 'info', + message: '客观断言完成;按 ai-test 的十分制审阅实际产物后记录质量评分。', + data: { + reports: result.reports, + csv: result.csv, + loadedNames, + usages: [...result.firstIo, ...result.secondIo].map((record) => ({ + runId: record.runId, + usage: record.usage ?? null + })) + } + }) } }) }, - 600_000 + 900_000 ) }) diff --git a/tests/case/knowledge/foundation/index-speed-thread-budget.test.ts b/tests/case/knowledge/foundation/index-speed-thread-budget.test.ts index 961fc96f..b4c34dac 100644 --- a/tests/case/knowledge/foundation/index-speed-thread-budget.test.ts +++ b/tests/case/knowledge/foundation/index-speed-thread-budget.test.ts @@ -61,6 +61,7 @@ describe('knowledge foundation', () => { it('index-speed-thread-budget', async () => { await runBackendCase({ caseId: 'index-speed-thread-budget', + knowledge: 'cold', harnessMode: 'tool-real', domain: 'knowledge', suite: 'foundation', diff --git a/tests/case/knowledge/foundation/kb-availability-contract.test.ts b/tests/case/knowledge/foundation/kb-availability-contract.test.ts index 38a2a451..e9611fff 100644 --- a/tests/case/knowledge/foundation/kb-availability-contract.test.ts +++ b/tests/case/knowledge/foundation/kb-availability-contract.test.ts @@ -21,6 +21,7 @@ describe('knowledge availability through real services', () => { domain: 'knowledge', suite: 'foundation', harnessMode: 'tool-real', + knowledge: 'cold', fixtures: [], runMode: 'single', description: '真实模型安装门禁、初始化失败、空库、全局网页、删除恢复及索引入口。', diff --git a/tests/case/knowledge/foundation/workspace-knowledge-cache-reuse.test.ts b/tests/case/knowledge/foundation/workspace-knowledge-cache-reuse.test.ts new file mode 100644 index 00000000..356de841 --- /dev/null +++ b/tests/case/knowledge/foundation/workspace-knowledge-cache-reuse.test.ts @@ -0,0 +1,140 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { describe, it } from 'vitest' +import { StorageService } from '../../../../src/main/services/storage/StorageService' +import { WorkspaceKnowledgePrepareService } from '../../../../src/main/services/workspace/WorkspaceKnowledgePrepareService' +import { createWorkspaceKnowledgeCache } from '../../../../src/main/services/workspace/WorkspaceKnowledgeCache' +import { prepareWorkspaceSource } from '../../../../src/main/services/workspace/worker/workspacePrepareWorker' +import { runBackendCase } from '../../../harness/backend/run-backend-case' + +describe('knowledge foundation', () => { + it('workspace-knowledge-cache-reuse', async () => { + await runBackendCase({ + caseId: 'workspace-knowledge-cache-reuse', + harnessMode: 'tool-real', + knowledge: 'cached', + domain: 'knowledge', + suite: 'foundation', + description: '真实解析和嵌入只计算缺失条目;全新存储重绑当前工作区,改文及改名安全失效。', + fixtures: ['tests/fixtures/workspace/工作/本周排期更新.txt'], + runMode: 'single', + execute: async (runtime) => { + await runtime.prepareEmbeddingModel() + const cache = createWorkspaceKnowledgeCache({ + root: path.join(runtime.workspaceRoot, '.computation-cache'), + pipeline: JSON.parse(process.env.NCURATOR_KNOWLEDGE_IDENTITY!) as { + split: string + embedding: string + } + })! + const firstFile = runtime.getFixtureWorkspacePath( + 'tests/fixtures/workspace/工作/本周排期更新.txt' + ) + const sourceBytes = await fs.readFile(firstFile) + const firstService = new WorkspaceKnowledgePrepareService({ + embeddingService: runtime.embeddingService, + documentsStore: runtime.documentsStore, + prepareSource: prepareWorkspaceSource + }) + const first = await firstService.indexLocalFile({ + workspaceId: 'original-workspace', + documentId: 'original-document', + filePath: firstFile, + documentName: path.basename(firstFile), + cache + }) + const secondRoot = path.join(runtime.workspaceRoot, 'fresh-workspace') + await fs.mkdir(secondRoot) + const secondFile = path.join(secondRoot, path.basename(firstFile)) + await fs.copyFile(firstFile, secondFile) + const storage = new StorageService({ + sqlitePath: path.join(secondRoot, 'fresh.sqlite'), + vectorPath: path.join(secondRoot, 'vectors') + }) + await storage.initialize() + try { + const secondService = new WorkspaceKnowledgePrepareService({ + embeddingService: runtime.embeddingService, + documentsStore: storage.documents, + prepareSource: prepareWorkspaceSource + }) + const second = await secondService.indexLocalFile({ + workspaceId: 'fresh-workspace', + documentId: 'fresh-document', + filePath: secondFile, + documentName: path.basename(secondFile), + cache + }) + const bound = await storage.documents.getDocumentById('fresh-document') + const old = await storage.documents.getDocumentById('original-document') + await fs.appendFile(secondFile, '\n新增排期:复核结束后再确认发布。') + const changed = await secondService.indexLocalFile({ + workspaceId: 'fresh-workspace', + documentId: 'fresh-document', + filePath: secondFile, + documentName: path.basename(secondFile), + cache + }) + const renamedFile = path.join(secondRoot, '最新排期.txt') + await fs.rename(secondFile, renamedFile) + const renamed = await secondService.indexLocalFile({ + workspaceId: 'fresh-workspace', + documentId: 'renamed-document', + filePath: renamedFile, + documentName: '最新排期.txt', + cache + }) + return { + first, + second, + changed, + renamed, + bound, + old, + sourcePreserved: Buffer.compare(sourceBytes, await fs.readFile(firstFile)) === 0 + } + } finally { + await storage.close() + } + }, + assert: async (result, runtime) => { + runtime.assertThat( + !result.first.usedCachedSplit && + !result.first.usedCachedVectors && + result.second.usedCachedSplit && + result.second.usedCachedVectors, + { + name: 'cold-then-hot-real-index', + message: '首轮真实计算,第二个全新工作区完全命中切片与向量缓存。', + data: { first: result.first, second: result.second } + } + ) + runtime.assertThat( + result.bound?.workspace_id === 'fresh-workspace' && + result.bound.file_path === result.second.filePath && + !result.old && + result.second.generationId !== result.first.generationId && + result.second.chunks.every( + (chunk) => !result.first.chunks.some((old) => old.id === chunk.id) + ), + { + name: 'isolated-store-rebinding', + message: '只复用计算结果;新 SQLite/LanceDB、文档、generation 与 chunk ID 均独立。' + } + ) + runtime.assertThat( + !result.changed.usedCachedSplit && + !result.changed.usedCachedVectors && + !result.renamed.usedCachedSplit && + !result.renamed.usedCachedVectors && + result.sourcePreserved && + result.renamed.chunks.some((chunk) => chunk.text.includes('最新排期')), + { + name: 'content-and-anchor-invalidations', + message: '改文失效,改名重新生成锚点与向量,原始资料未修改。' + } + ) + } + }) + }, 300_000) +}) diff --git a/tests/case/settings/vector/vector-model-download-delete-flow.e2e.test.ts b/tests/case/settings/vector/vector-model-download-delete-flow.e2e.test.ts index 46fe6ca7..0ed7d120 100644 --- a/tests/case/settings/vector/vector-model-download-delete-flow.e2e.test.ts +++ b/tests/case/settings/vector/vector-model-download-delete-flow.e2e.test.ts @@ -25,6 +25,7 @@ describe('settings vector real download e2e', () => { description: '用户通过真实 Settings 页面下载产品列表中的向量模型,观察进度和页面恢复,再完成删除确认、取消与成功删除。', fixtures: [], + knowledge: 'cold', serviceMode: 'real', extraEnv: { NCURATOR_MODELS_PATH: modelsPath diff --git a/tests/case/settings/vector/vector-model-download-delete-while-active.e2e.test.ts b/tests/case/settings/vector/vector-model-download-delete-while-active.e2e.test.ts index 33995bf0..b221fe70 100644 --- a/tests/case/settings/vector/vector-model-download-delete-while-active.e2e.test.ts +++ b/tests/case/settings/vector/vector-model-download-delete-while-active.e2e.test.ts @@ -25,6 +25,7 @@ describe('settings vector real download e2e', () => { description: '用户在真实下载过程中删除向量模型:下载被取消、已下载文件被移除、页面回到可下载状态且不提示失败。', fixtures: [], + knowledge: 'cold', serviceMode: 'real', extraEnv: { NCURATOR_MODELS_PATH: modelsPath diff --git a/tests/case/settings/vector/vector-model-download-source-switch.e2e.test.ts b/tests/case/settings/vector/vector-model-download-source-switch.e2e.test.ts index 5617fb23..7b62cfa0 100644 --- a/tests/case/settings/vector/vector-model-download-source-switch.e2e.test.ts +++ b/tests/case/settings/vector/vector-model-download-source-switch.e2e.test.ts @@ -102,6 +102,7 @@ describe('settings vector download source switch e2e', () => { description: '默认下载源失效时,用户通过“更换源下载”选择另一源完成模型安装,验证失败清理、弹窗标注失败源与换源重下的完整链路。', fixtures: [], + knowledge: 'cold', serviceMode: 'real', extraEnv: { NCURATOR_MODELS_PATH: modelsPath, diff --git a/tests/case/settings/workspace/core/workspace-set-root-path.e2e.test.ts b/tests/case/settings/workspace/core/workspace-set-root-path.e2e.test.ts index 95580106..32f41da7 100644 --- a/tests/case/settings/workspace/core/workspace-set-root-path.e2e.test.ts +++ b/tests/case/settings/workspace/core/workspace-set-root-path.e2e.test.ts @@ -27,6 +27,7 @@ describe('settings workspace e2e', () => { groups: ['core'], description: '添加目录、子路径拒绝提示、父路径自动替换与扫描后可索引数刷新。', fixtures: [], + knowledge: 'cached', serviceMode: 'real', execution: 'exclusive', windowMode: 'visible', diff --git a/tests/case/settings/workspace/files/workspace-detail-status-and-error.e2e.test.ts b/tests/case/settings/workspace/files/workspace-detail-status-and-error.e2e.test.ts index a7a86b0c..cb875de6 100644 --- a/tests/case/settings/workspace/files/workspace-detail-status-and-error.e2e.test.ts +++ b/tests/case/settings/workspace/files/workspace-detail-status-and-error.e2e.test.ts @@ -22,6 +22,7 @@ describe('settings workspace e2e', () => { groups: ['files'], description: '文件 tab 持续展示需索引/失败摘要,并在索引失败后保留文件级错误状态。', fixtures: [], + knowledge: 'cached', serviceMode: 'real', runMode: 'single', execute: async (runtime) => { diff --git a/tests/case/settings/workspace/files/workspace-index-speed.e2e.test.ts b/tests/case/settings/workspace/files/workspace-index-speed.e2e.test.ts index 06e126ab..7a7e2ae4 100644 --- a/tests/case/settings/workspace/files/workspace-index-speed.e2e.test.ts +++ b/tests/case/settings/workspace/files/workspace-index-speed.e2e.test.ts @@ -192,6 +192,7 @@ describe('settings workspace e2e', () => { groups: ['files'], description: '独立小目录两次真实更新索引,断言慢/高速嵌入进程的操作系统线程增量。', fixtures: [], + knowledge: 'cold', serviceMode: 'real', execution: 'exclusive', runMode: 'single', diff --git a/tests/case/settings/workspace/files/workspace-prepare-cancel-after-complete.e2e.test.ts b/tests/case/settings/workspace/files/workspace-prepare-cancel-after-complete.e2e.test.ts index de9ba9b3..94361554 100644 --- a/tests/case/settings/workspace/files/workspace-prepare-cancel-after-complete.e2e.test.ts +++ b/tests/case/settings/workspace/files/workspace-prepare-cancel-after-complete.e2e.test.ts @@ -29,6 +29,7 @@ describe('settings workspace e2e', () => { groups: ['files'], description: '运行中打开取消确认框,任务自然成功后关闭确认框并保留完成结果。', fixtures: [], + knowledge: 'cached', serviceMode: 'real', runMode: 'single', execute: async (runtime) => { diff --git a/tests/case/settings/workspace/files/workspace-prepare-cancel.e2e.test.ts b/tests/case/settings/workspace/files/workspace-prepare-cancel.e2e.test.ts index a87cc711..ac6b0da3 100644 --- a/tests/case/settings/workspace/files/workspace-prepare-cancel.e2e.test.ts +++ b/tests/case/settings/workspace/files/workspace-prepare-cancel.e2e.test.ts @@ -34,6 +34,7 @@ describe('settings workspace e2e', () => { groups: ['files'], description: '更新索引进行中可取消,停止后按钮恢复且仍有未完成文件。', fixtures: [], + knowledge: 'cached', serviceMode: 'real', runMode: 'single', execute: async (runtime) => { diff --git a/tests/case/settings/workspace/files/workspace-prepare-failed-retry.e2e.test.ts b/tests/case/settings/workspace/files/workspace-prepare-failed-retry.e2e.test.ts index 68759acb..b5697175 100644 --- a/tests/case/settings/workspace/files/workspace-prepare-failed-retry.e2e.test.ts +++ b/tests/case/settings/workspace/files/workspace-prepare-failed-retry.e2e.test.ts @@ -22,6 +22,7 @@ describe('settings workspace e2e', () => { groups: ['files'], description: '已有失败文件再次点击更新索引时,应被纳入本次处理范围。', fixtures: [], + knowledge: 'cached', serviceMode: 'real', runMode: 'single', execute: async (runtime) => { diff --git a/tests/case/settings/workspace/files/workspace-prepare-file-failure-isolated.e2e.test.ts b/tests/case/settings/workspace/files/workspace-prepare-file-failure-isolated.e2e.test.ts index 6b37d9d6..3f826598 100644 --- a/tests/case/settings/workspace/files/workspace-prepare-file-failure-isolated.e2e.test.ts +++ b/tests/case/settings/workspace/files/workspace-prepare-file-failure-isolated.e2e.test.ts @@ -24,6 +24,7 @@ describe('settings workspace e2e', () => { description: '单个文件索引失败不应导致 Settings 页面或 Electron 应用退出,并应留下文件级失败状态。', fixtures: ['tests/fixtures/workspace/sample-edit.docx'], + knowledge: 'cached', serviceMode: 'real', runMode: 'single', execute: async (runtime) => { diff --git a/tests/case/settings/workspace/files/workspace-prepare-progress-restore.e2e.test.ts b/tests/case/settings/workspace/files/workspace-prepare-progress-restore.e2e.test.ts index 08109743..238211e2 100644 --- a/tests/case/settings/workspace/files/workspace-prepare-progress-restore.e2e.test.ts +++ b/tests/case/settings/workspace/files/workspace-prepare-progress-restore.e2e.test.ts @@ -39,6 +39,7 @@ describe('settings workspace e2e', () => { groups: ['files'], description: '文件索引执行中离开并返回工作空间页后,应恢复持久化进度块和取消按钮状态。', fixtures: [], + knowledge: 'cached', serviceMode: 'real', runMode: 'single', execute: async (runtime) => { diff --git a/tests/case/settings/workspace/files/workspace-prepare-refresh.e2e.test.ts b/tests/case/settings/workspace/files/workspace-prepare-refresh.e2e.test.ts index 3d305ce8..f7052bac 100644 --- a/tests/case/settings/workspace/files/workspace-prepare-refresh.e2e.test.ts +++ b/tests/case/settings/workspace/files/workspace-prepare-refresh.e2e.test.ts @@ -24,7 +24,7 @@ describe('settings workspace e2e', () => { description: '点击更新索引后,页面按增量策略执行,并在完成后刷新文件索引状态。', fixtures: ['tests/fixtures/workspace'], serviceMode: 'real', - execution: 'warmup', + knowledge: 'cached', runMode: 'single', execute: async (runtime) => { const { page } = runtime @@ -115,7 +115,11 @@ describe('settings workspace e2e', () => { } }) - const result = await waitForFileIndexJobIdle(page, workspaceId) + const result = await waitForFileIndexJobIdle( + page, + workspaceId, + SETTINGS_REAL_WORKSPACE_LONG_E2E_TIMEOUT_MS + ) directoryCallsAfterCompletion = await page.evaluate( () => ( diff --git a/tests/case/settings/workspace/files/workspace-prepare-sequential-addresses.e2e.test.ts b/tests/case/settings/workspace/files/workspace-prepare-sequential-addresses.e2e.test.ts index 9921c87e..ac7bd66a 100644 --- a/tests/case/settings/workspace/files/workspace-prepare-sequential-addresses.e2e.test.ts +++ b/tests/case/settings/workspace/files/workspace-prepare-sequential-addresses.e2e.test.ts @@ -31,6 +31,7 @@ describe('settings workspace e2e', () => { groups: ['files'], description: '多个文件地址更新索引时,前一个地址终态后再启动下一个。', fixtures: [], + knowledge: 'cached', serviceMode: 'real', execution: 'exclusive', windowMode: 'visible', diff --git a/tests/case/settings/workspace/files/workspace-scan-progress-refresh.e2e.test.ts b/tests/case/settings/workspace/files/workspace-scan-progress-refresh.e2e.test.ts index 69f0434e..5bd46bc2 100644 --- a/tests/case/settings/workspace/files/workspace-scan-progress-refresh.e2e.test.ts +++ b/tests/case/settings/workspace/files/workspace-scan-progress-refresh.e2e.test.ts @@ -34,6 +34,7 @@ describe('settings workspace e2e', () => { groups: ['files'], description: '点击检查更新后,页面展示执行中状态,并在完成后刷新目录树和索引摘要。', fixtures: [], + knowledge: 'cached', serviceMode: 'real', runMode: 'single', execute: async (runtime) => { diff --git a/tests/case/unit/knowledge/packaged-knowledge-identity.test.ts b/tests/case/unit/knowledge/packaged-knowledge-identity.test.ts new file mode 100644 index 00000000..f73fbd7b --- /dev/null +++ b/tests/case/unit/knowledge/packaged-knowledge-identity.test.ts @@ -0,0 +1,35 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { createPackage } from '@electron/asar' +import { describe, expect, it } from 'vitest' +import { readPackagedKnowledgeCacheIdentity } from '../../../../scripts/test/package-app.mjs' + +describe('installed knowledge computation identity', () => { + for (const platform of ['darwin', 'win32']) { + it(`reads the actual ${platform} archive and rejects legacy or damaged identities`, async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'ncurator-installed-identity-')) + try { + const source = path.join(root, 'source') + const resources = + platform === 'darwin' + ? path.join(root, 'Contents', 'Resources') + : path.join(root, 'resources') + await mkdir(path.join(source, 'out', 'main'), { recursive: true }) + await mkdir(resources, { recursive: true }) + await createPackage(source, path.join(resources, 'app.asar')) + expect(readPackagedKnowledgeCacheIdentity(root, platform)).toBeNull() + const identity = { split: 'a'.repeat(64), embedding: 'b'.repeat(64) } + const metadata = path.join(source, 'out', 'main', 'knowledge-cache-identity.json') + await writeFile(metadata, JSON.stringify(identity)) + await createPackage(source, path.join(resources, 'app.asar')) + expect(readPackagedKnowledgeCacheIdentity(root, platform)).toEqual(identity) + await writeFile(metadata, '{"split":"broken"}') + await createPackage(source, path.join(resources, 'app.asar')) + expect(readPackagedKnowledgeCacheIdentity(root, platform)).toBeNull() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + } +}) diff --git a/tests/case/unit/knowledge/workspace-knowledge-cache.test.ts b/tests/case/unit/knowledge/workspace-knowledge-cache.test.ts index 0136270d..e072dd5b 100644 --- a/tests/case/unit/knowledge/workspace-knowledge-cache.test.ts +++ b/tests/case/unit/knowledge/workspace-knowledge-cache.test.ts @@ -1,138 +1,167 @@ -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { mkdtemp, readFile, readdir, rm, writeFile, mkdir } from 'node:fs/promises' import os from 'node:os' +import { spawnSync } from 'node:child_process' import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { createWorkspaceKnowledgeCache } from '../../../../src/main/services/workspace/WorkspaceKnowledgeCache' +import { fingerprintKnowledgePipeline } from '../../../../scripts/build/runtime/knowledge-cache-identity.mjs' -const createdDirectories: string[] = [] -const originalEnabled = process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE -const originalPath = process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_PATH -const originalStatsPath = process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_STATS_PATH +let root: string +const originalModels = process.env.NCURATOR_MODELS_PATH +const pipeline = { split: 'parser-a', embedding: 'embedding-a' } +const split = { contentHash: 'content-a', fileExt: '.md', chunks: [{ text: 'hello', page: 1 }] } +const key = { contentHash: 'content-a', fileExt: '.md', sourceName: 'notes.md' } +const vector = { + contentHash: 'content-a', + fileExt: '.md', + modelName: 'model-a', + vectors: [[1, 2, 3]] +} +const vectorKey = { ...key, modelName: 'model-a', texts: ['hello'] } +const cache = (identity = pipeline) => + createWorkspaceKnowledgeCache({ root: path.join(root, 'cache'), pipeline: identity })! +beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'ncurator-knowledge-cache-')) + process.env.NCURATOR_MODELS_PATH = path.join(root, 'models') + await mkdir(path.join(root, 'models', 'model-a'), { recursive: true }) + await writeFile(path.join(root, 'models', 'model-a', 'model.onnx'), 'asset-a') +}) afterEach(async () => { - if (originalEnabled === undefined) delete process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE - else process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE = originalEnabled - if (originalPath === undefined) delete process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_PATH - else process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_PATH = originalPath - if (originalStatsPath === undefined) { - delete process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_STATS_PATH - } else { - process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_STATS_PATH = originalStatsPath - } - await Promise.all( - createdDirectories.splice(0).map(async (directory) => { - await rm(directory, { recursive: true, force: true }) - }) - ) + if (originalModels === undefined) delete process.env.NCURATOR_MODELS_PATH + else process.env.NCURATOR_MODELS_PATH = originalModels + await rm(root, { recursive: true, force: true }) }) describe('workspace knowledge cache', () => { - it('按内容、扩展名和模型隔离缓存,并记录 hit/miss', async () => { - const cacheRoot = await mkdtemp(path.join(os.tmpdir(), 'ncurator-knowledge-cache-')) - createdDirectories.push(cacheRoot) - process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE = '1' - process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_PATH = cacheRoot - - const cache = createWorkspaceKnowledgeCache() - if (!cache) throw new Error('知识库缓存没有启用') - + it('isolates split identity by contents, extension, name and parser while preserving same input hits', async () => { + const first = cache() + await expect(first.loadSplitSnapshot(key)).resolves.toBeNull() + await first.saveSplitSnapshot(split, key.sourceName) + await expect(cache().loadSplitSnapshot(key)).resolves.toEqual(split) + for (const change of [ + { contentHash: 'new' }, + { fileExt: '.txt' }, + { sourceName: 'renamed.md' } + ]) { + await expect(first.loadSplitSnapshot({ ...key, ...change })).resolves.toBeNull() + } await expect( - cache.loadSplitSnapshot({ contentHash: 'content-a', fileExt: '.md' }) - ).resolves.toBeNull() - await cache.saveSplitSnapshot({ - contentHash: 'content-a', - fileExt: '.md', - chunks: [{ text: 'hello', page: 1 }] - }) - await expect( - cache.loadSplitSnapshot({ contentHash: 'content-a', fileExt: '.md' }) - ).resolves.toMatchObject({ contentHash: 'content-a' }) - await expect( - cache.loadSplitSnapshot({ contentHash: 'content-b', fileExt: '.md' }) + cache({ ...pipeline, split: 'parser-b' }).loadSplitSnapshot(key) ).resolves.toBeNull() await expect( - cache.loadSplitSnapshot({ contentHash: 'content-a', fileExt: '.txt' }) - ).resolves.toBeNull() - - await expect( - cache.loadVectorSnapshot({ - contentHash: 'content-a', - fileExt: '.md', - modelName: 'model-a' - }) - ).resolves.toBeNull() - await cache.saveVectorSnapshot({ - contentHash: 'content-a', - fileExt: '.md', - modelName: 'model-a', - vectors: [[1, 2, 3]] - }) - await expect( - cache.loadVectorSnapshot({ - contentHash: 'content-a', - fileExt: '.md', - modelName: 'model-a' - }) - ).resolves.toMatchObject({ modelName: 'model-a' }) - await expect( - cache.loadVectorSnapshot({ - contentHash: 'content-a', - fileExt: '.md', - modelName: 'model-b' - }) - ).resolves.toBeNull() - - const stats = JSON.parse(await readFile(path.join(cacheRoot, 'stats.json'), 'utf8')) as Record< - string, - number - > - expect(stats).toEqual({ - splitCacheHits: 1, - splitCacheMisses: 3, - vectorCacheHits: 1, - vectorCacheMisses: 2 - }) + cache({ ...pipeline, embedding: 'embedding-b' }).loadSplitSnapshot(key) + ).resolves.toEqual(split) }) - it('允许多个 E2E case 共享内容缓存并分别记录统计', async () => { - const cacheRoot = await mkdtemp(path.join(os.tmpdir(), 'ncurator-shared-knowledge-cache-')) - createdDirectories.push(cacheRoot) - const statsPath = path.join(cacheRoot, 'case-stats', 'case-a', 'stats.json') - process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE = '1' - process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_PATH = cacheRoot - process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_STATS_PATH = statsPath - - const firstCache = createWorkspaceKnowledgeCache() - process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_STATS_PATH = path.join( - cacheRoot, - 'case-stats', - 'case-b', - 'stats.json' - ) - const secondCache = createWorkspaceKnowledgeCache() - if (!firstCache || !secondCache) throw new Error('知识库缓存没有启用') - await firstCache.loadSplitSnapshot({ contentHash: 'shared-content', fileExt: '.md' }) - const snapshot = { - contentHash: 'shared-content', - fileExt: '.md', - chunks: [{ text: 'shared', page: 1 }] - } - await Promise.all([ - firstCache.saveSplitSnapshot(snapshot), - secondCache.saveSplitSnapshot(snapshot) - ]) + it('keys vectors by actual ordered texts, model bytes and embedding code, then rebinds current source metadata', async () => { + await cache().saveVectorSnapshot(vector, vectorKey.texts) + await expect(cache().loadVectorSnapshot(vectorKey)).resolves.toEqual(vector) await expect( - secondCache.loadSplitSnapshot({ contentHash: 'shared-content', fileExt: '.md' }) - ).resolves.toMatchObject({ contentHash: 'shared-content' }) - - await expect(readFile(statsPath, 'utf8')).resolves.toContain('splitCacheMisses') + cache().loadVectorSnapshot({ ...vectorKey, contentHash: 'other-source', fileExt: '.txt' }) + ).resolves.toEqual({ ...vector, contentHash: 'other-source', fileExt: '.txt' }) await expect( - readFile(path.join(cacheRoot, 'case-stats', 'case-b', 'stats.json'), 'utf8') - ).resolves.toContain('splitCacheHits') - await expect(readFile(path.join(cacheRoot, 'stats.json'), 'utf8')).rejects.toThrow() - const [splitFile] = await readdir(path.join(cacheRoot, 'splits')) - await expect(readFile(path.join(cacheRoot, 'splits', splitFile), 'utf8')).resolves.toContain( - 'shared-content' + cache().loadVectorSnapshot({ ...vectorKey, texts: ['different'] }) + ).resolves.toBeNull() + await expect( + cache().loadVectorSnapshot({ ...vectorKey, texts: ['hello', 'second'] }) + ).resolves.toBeNull() + await expect( + cache({ ...pipeline, embedding: 'embedding-b' }).loadVectorSnapshot(vectorKey) + ).resolves.toBeNull() + await expect( + cache({ ...pipeline, split: 'parser-b' }).loadVectorSnapshot(vectorKey) + ).resolves.toEqual(vector) + await writeFile(path.join(root, 'models', 'model-a', 'model.onnx'), 'asset-b') + await expect(cache().loadVectorSnapshot(vectorKey)).resolves.toBeNull() + }) + + it('serializes missing work across consumers, retains completed entries on failure, and atomically rejects corruption', async () => { + const first = cache(), + second = cache() + let computations = 0 + const warm = async (target: ReturnType) => + target.withLock('same-source', async () => { + if (await target.loadSplitSnapshot(key)) return + computations += 1 + await target.saveSplitSnapshot(split, key.sourceName) + }) + await Promise.all([warm(first), warm(second), warm(cache())]) + expect(computations).toBe(1) + await expect( + first.withLock('same-source', async () => { + throw new Error('interrupted') + }) + ).rejects.toThrow('interrupted') + await warm(second) + expect(computations).toBe(1) + const [file] = await readdir(path.join(root, 'cache', 'splits')) + await writeFile(path.join(root, 'cache', 'splits', file), '{"incomplete":true}') + await warm(first) + expect(computations).toBe(2) + expect(await readdir(path.join(root, 'cache', 'locks'))).toEqual([]) + const stats = JSON.parse(await readFile(path.join(root, 'cache', 'stats.json'), 'utf8')) + expect(stats.splitCacheHits).toBe(3) + expect(stats.splitCacheMisses).toBe(2) + }) + + it('cancels a waiting consumer without removing the active computation lock', async () => { + const target = cache() + const signal = new AbortController() + await target.withLock('busy-source', async () => { + const waiting = target.withLock( + 'busy-source', + async () => { + throw new Error('must not start') + }, + signal.signal + ) + signal.abort() + await expect(waiting).rejects.toThrow('INDEX_PREPARE_CANCELLED') + expect(await readdir(path.join(root, 'cache', 'locks'))).toHaveLength(1) + }) + expect(await readdir(path.join(root, 'cache', 'locks'))).toEqual([]) + }) + + it('reclaims a terminated process lock without deleting a new owner', async () => { + const target = cache() + let directory = '' + await target.withLock('interrupted-source', async () => { + const [name] = await readdir(path.join(root, 'cache', 'locks')) + directory = path.join(root, 'cache', 'locks', name) + }) + const child = spawnSync(process.execPath, ['-e', 'process.exit(0)']) + expect(child.status).toBe(0) + await mkdir(directory) + await writeFile(path.join(directory, `${child.pid}-interrupted`), '') + let recovered = false + await target.withLock('interrupted-source', async () => { + recovered = true + }) + expect(recovered).toBe(true) + expect(await readdir(path.join(root, 'cache', 'locks'))).toEqual([]) + }) + + it('automatically follows newly imported runtime dependencies while ignoring unrelated files and type-only imports', async () => { + await writeFile( + path.join(root, 'entry.ts'), + "import { parse } from './parser'; import type { Shape } from './types'; export const run = parse;" ) + await writeFile(path.join(root, 'parser.ts'), 'export const parse = () => 1') + await writeFile(path.join(root, 'types.ts'), 'export type Shape = string') + const fingerprint = () => fingerprintKnowledgePipeline(root, ['entry.ts']) + const initial = await fingerprint() + await writeFile(path.join(root, 'unrelated-ui.ts'), 'export const ui = 2') + await writeFile(path.join(root, 'types.ts'), 'export type Shape = number') + expect(await fingerprint()).toBe(initial) + await writeFile( + path.join(root, 'parser.ts'), + "import { value } from './new-dependency'; export const parse = () => value" + ) + await writeFile(path.join(root, 'new-dependency.ts'), 'export const value = 1') + const imported = await fingerprint() + expect(imported).not.toBe(initial) + await writeFile(path.join(root, 'new-dependency.ts'), 'export const value = 2') + expect(await fingerprint()).not.toBe(imported) }) }) diff --git a/tests/case/unit/release/app-update-publish-config.test.ts b/tests/case/unit/release/app-update-publish-config.test.ts index b5a2eebc..f51ff1ac 100644 --- a/tests/case/unit/release/app-update-publish-config.test.ts +++ b/tests/case/unit/release/app-update-publish-config.test.ts @@ -189,7 +189,7 @@ describe('app update publish config', () => { expect(storageConfig).toContain('download.guanzhangai.cn') }) - it('shares source caches directly and warms packaged E2E before parallel workers', async () => { + it('shares computation caches without adding unrelated E2E cases', async () => { const [testWorkflow, architectureValidator, warmupCase, macRelease] = await Promise.all([ readFile(path.resolve('scripts/test/workflow.mjs'), 'utf8'), readFile(path.resolve('scripts/test/validate-test-architecture.mjs'), 'utf8'), @@ -200,18 +200,11 @@ describe('app update publish config', () => { readFile(path.resolve('scripts/release/macos.mjs'), 'utf8') ]) - expect(testWorkflow).toContain("task.execution === 'warmup'") - expect(testWorkflow).toContain("cacheMode: 'direct'") - expect(testWorkflow).toContain("cacheMode: 'warmup'") - expect(testWorkflow).toContain("task.execution !== 'exclusive' && task.execution !== 'warmup'") - expect(testWorkflow).toContain('VECTOR_WARMUP_TEST_FILE') - expect(testWorkflow).toContain('testFiles = [...testFiles, VECTOR_WARMUP_TEST_FILE]') - expect(testWorkflow).toContain("task.execution === 'warmup' ? '' : testNamePattern") + expect(testWorkflow).not.toContain('VECTOR_WARMUP_TEST_FILE') + expect(testWorkflow).not.toContain('seedCaseKnowledgeCache') + expect(testWorkflow).not.toContain('--reuse-knowledge-cache') expect(testWorkflow).toContain('buildDirectKnowledgeCacheEnv') expect(testWorkflow).toContain('NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_STATS_PATH') - expect(testWorkflow).toContain('vector warm-up failed; parallel E2E will not start') - expect(testWorkflow).toContain('seedCaseKnowledgeCache') - expect(testWorkflow).toContain("path.basename(source) !== 'stats.json'") expect(testWorkflow).not.toContain('macos-x64') expect(testWorkflow).toContain('const LIVE_WORKERS = 3') expect(testWorkflow).not.toContain('LIVE_BACKEND_RETRIES') @@ -226,8 +219,8 @@ describe('app update publish config', () => { expect(testWorkflow).toContain("spawnSync('taskkill.exe'") expect(testWorkflow).toContain("input.type === 'e2e' && input.target === 'package'") expect(testWorkflow).toContain("prepareBetterSqliteNative('node')") - expect(architectureValidator).toContain('warmupFiles.length !== 1') - expect(warmupCase).toContain("execution: 'warmup'") + expect(architectureValidator).toContain('unsupported knowledge policy') + expect(warmupCase).toContain("knowledge: 'cached'") }) it('keeps LangSmith tracing opt-in by default', async () => { diff --git a/tests/case/workbench/conversation/composer/composer-attachments-context.e2e.test.ts b/tests/case/workbench/conversation/composer/composer-attachments-context.e2e.test.ts index e7abfd43..686cea43 100644 --- a/tests/case/workbench/conversation/composer/composer-attachments-context.e2e.test.ts +++ b/tests/case/workbench/conversation/composer/composer-attachments-context.e2e.test.ts @@ -132,6 +132,7 @@ describe('workbench conversation e2e', () => { groups: ['composer'], description: '验证文件与文件夹作为路径附件、无文件大小限制、附件卡片操作和发送后横向滚动。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'exclusive', windowMode: 'visible', diff --git a/tests/case/workbench/conversation/composer/context-ring-usage.e2e.test.ts b/tests/case/workbench/conversation/composer/context-ring-usage.e2e.test.ts index 655a7768..e67859dc 100644 --- a/tests/case/workbench/conversation/composer/context-ring-usage.e2e.test.ts +++ b/tests/case/workbench/conversation/composer/context-ring-usage.e2e.test.ts @@ -24,6 +24,7 @@ describe('workbench conversation e2e', () => { groups: ['composer'], description: '验证智能模式上下文圆圈会刷新并展示已用比例与 tokens。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/conversation/composer/docx-selection-reference.e2e.test.ts b/tests/case/workbench/conversation/composer/docx-selection-reference.e2e.test.ts index 8269777d..1c4c3586 100644 --- a/tests/case/workbench/conversation/composer/docx-selection-reference.e2e.test.ts +++ b/tests/case/workbench/conversation/composer/docx-selection-reference.e2e.test.ts @@ -205,6 +205,7 @@ describe('workbench conversation e2e', () => { groups: ['composer'], description: '通过真实鼠标选区和键盘操作验证混合正文同步、定位及提交清理。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', windowMode: 'visible', execution: 'exclusive', diff --git a/tests/case/workbench/conversation/composer/multi-format-selection-reference.e2e.test.ts b/tests/case/workbench/conversation/composer/multi-format-selection-reference.e2e.test.ts index adef6b6c..c2e58782 100644 --- a/tests/case/workbench/conversation/composer/multi-format-selection-reference.e2e.test.ts +++ b/tests/case/workbench/conversation/composer/multi-format-selection-reference.e2e.test.ts @@ -260,6 +260,7 @@ describe('workbench conversation e2e', () => { groups: ['composer'], description: '真实多格式选区引用、定位及混合发送。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', windowMode: 'visible', execution: 'exclusive', diff --git a/tests/case/workbench/conversation/composer/reference-mention-keyboard.e2e.test.ts b/tests/case/workbench/conversation/composer/reference-mention-keyboard.e2e.test.ts index ce44b6ca..063dc536 100644 --- a/tests/case/workbench/conversation/composer/reference-mention-keyboard.e2e.test.ts +++ b/tests/case/workbench/conversation/composer/reference-mention-keyboard.e2e.test.ts @@ -25,6 +25,7 @@ describe('workbench conversation e2e', () => { groups: ['composer'], description: '@ 候选列表用下键移动时,选中项应停在下一项,不应被刷新回第一项。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', windowMode: 'visible', runMode: 'single', diff --git a/tests/case/workbench/conversation/message/user-message-actions.e2e.test.ts b/tests/case/workbench/conversation/message/user-message-actions.e2e.test.ts index f966d086..7980cc68 100644 --- a/tests/case/workbench/conversation/message/user-message-actions.e2e.test.ts +++ b/tests/case/workbench/conversation/message/user-message-actions.e2e.test.ts @@ -112,6 +112,7 @@ describe('workbench conversation e2e', () => { description: '验证用户消息 hover 操作、latest turn 编辑撤回、running 态隐藏编辑,以及用户消息附件独立展示和输入框附件关闭按钮无遮挡。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'exclusive', windowMode: 'visible', diff --git a/tests/case/workbench/conversation/partner/partner-create-and-name.e2e.test.ts b/tests/case/workbench/conversation/partner/partner-create-and-name.e2e.test.ts index f70f699d..4ee6552d 100644 --- a/tests/case/workbench/conversation/partner/partner-create-and-name.e2e.test.ts +++ b/tests/case/workbench/conversation/partner/partner-create-and-name.e2e.test.ts @@ -34,6 +34,7 @@ describe('workbench conversation e2e', () => { description: '真实 UI 无伙伴时展示空态;通过创建弹窗填写名称和形象后,侧栏标题就是名称,发消息不改名。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/conversation/partner/partner-delete-confirmation.e2e.test.ts b/tests/case/workbench/conversation/partner/partner-delete-confirmation.e2e.test.ts index 4b43ce51..e91d6d7f 100644 --- a/tests/case/workbench/conversation/partner/partner-delete-confirmation.e2e.test.ts +++ b/tests/case/workbench/conversation/partner/partner-delete-confirmation.e2e.test.ts @@ -29,6 +29,7 @@ describe('workbench conversation e2e', () => { groups: ['partner'], description: '验证原生删除确认覆盖内置浏览器,取消不影响网页运行,确认后删除伙伴。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'exclusive', windowMode: 'visible', diff --git a/tests/case/workbench/conversation/timeline/timeline-day-jump.e2e.test.ts b/tests/case/workbench/conversation/timeline/timeline-day-jump.e2e.test.ts index c721f274..a646b5d2 100644 --- a/tests/case/workbench/conversation/timeline/timeline-day-jump.e2e.test.ts +++ b/tests/case/workbench/conversation/timeline/timeline-day-jump.e2e.test.ts @@ -27,6 +27,7 @@ describe('workbench conversation e2e', () => { groups: ['timeline'], description: '有消息后时间轴展示今天节点,点击后跳到当天最早一条消息。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/result/apply/apply-workspace-collapsed-restore.e2e.test.ts b/tests/case/workbench/result/apply/apply-workspace-collapsed-restore.e2e.test.ts index 8d4697e7..33b62850 100644 --- a/tests/case/workbench/result/apply/apply-workspace-collapsed-restore.e2e.test.ts +++ b/tests/case/workbench/result/apply/apply-workspace-collapsed-restore.e2e.test.ts @@ -49,6 +49,7 @@ describe('workbench result e2e', () => { description: '真实 AI 修改文件后,审核模式展开状态下折叠面板,切换伙伴再切回时面板必须保持折叠,异步记录刷新不得重新展开。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/result/apply/docx-apply-force-revert-warning.e2e.test.ts b/tests/case/workbench/result/apply/docx-apply-force-revert-warning.e2e.test.ts index 984cfb6b..f15dad75 100644 --- a/tests/case/workbench/result/apply/docx-apply-force-revert-warning.e2e.test.ts +++ b/tests/case/workbench/result/apply/docx-apply-force-revert-warning.e2e.test.ts @@ -36,6 +36,7 @@ describe('workbench result e2e', () => { groups: ['apply'], description: 'AI 写回后测试进程真实外部改 docx,验证首次回退风险提示和确认后强制回退。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/result/apply/docx-apply-panel-preview-and-revert.e2e.test.ts b/tests/case/workbench/result/apply/docx-apply-panel-preview-and-revert.e2e.test.ts index db407779..73da9d76 100644 --- a/tests/case/workbench/result/apply/docx-apply-panel-preview-and-revert.e2e.test.ts +++ b/tests/case/workbench/result/apply/docx-apply-panel-preview-and-revert.e2e.test.ts @@ -33,6 +33,7 @@ describe('workbench result e2e', () => { groups: ['apply'], description: '真实 AI 修改真实 docx 后,验证 assistant 修改入口、右侧预览和干净回退。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', extraEnv: { NCURATOR_E2E_OPEN_WITH_NOOP: '1' diff --git a/tests/case/workbench/result/apply/docx-attachment-apply-status-and-cell-diff.e2e.test.ts b/tests/case/workbench/result/apply/docx-attachment-apply-status-and-cell-diff.e2e.test.ts index bf3f0b83..79019906 100644 --- a/tests/case/workbench/result/apply/docx-attachment-apply-status-and-cell-diff.e2e.test.ts +++ b/tests/case/workbench/result/apply/docx-attachment-apply-status-and-cell-diff.e2e.test.ts @@ -48,6 +48,7 @@ describe('workbench result e2e', () => { description: '真实 AI 修改工作区内 DOCX 附件中的已有表格单元格后,验证审核 Diff 固定快照和预览模式监听真实资产文件。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/result/apply/docx-review-position-and-inline-diff.e2e.test.ts b/tests/case/workbench/result/apply/docx-review-position-and-inline-diff.e2e.test.ts index 732d88f4..482f9df6 100644 --- a/tests/case/workbench/result/apply/docx-review-position-and-inline-diff.e2e.test.ts +++ b/tests/case/workbench/result/apply/docx-review-position-and-inline-diff.e2e.test.ts @@ -81,6 +81,7 @@ describe('workbench result e2e', () => { groups: ['apply'], description: '真实 AI 修改真实 docx 后,验证 assistant 修改入口、右侧预览和干净回退。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', extraEnv: { NCURATOR_E2E_OPEN_WITH_NOOP: '1' diff --git a/tests/case/workbench/result/apply/excel-create-diff-and-revert.e2e.test.ts b/tests/case/workbench/result/apply/excel-create-diff-and-revert.e2e.test.ts index baaba5f2..0f500716 100644 --- a/tests/case/workbench/result/apply/excel-create-diff-and-revert.e2e.test.ts +++ b/tests/case/workbench/result/apply/excel-create-diff-and-revert.e2e.test.ts @@ -30,6 +30,7 @@ describe('workbench result e2e', () => { groups: ['apply'], description: '真实 AI 创建 Excel 后,验证全部新增单元格 Diff 和回退删除文件。', fixtures: [], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/result/apply/excel-update-diff-and-revert.e2e.test.ts b/tests/case/workbench/result/apply/excel-update-diff-and-revert.e2e.test.ts index 8e0fd814..f11f8a66 100644 --- a/tests/case/workbench/result/apply/excel-update-diff-and-revert.e2e.test.ts +++ b/tests/case/workbench/result/apply/excel-update-diff-and-revert.e2e.test.ts @@ -71,6 +71,7 @@ describe('workbench result e2e', () => { groups: ['apply'], description: '真实 AI 更新 Excel 后,验证单网格红绿 diff、干净实际文件和回撤。', fixtures: [], + knowledge: 'cached', serviceMode: 'real', extraEnv: { NCURATOR_E2E_OPEN_WITH_NOOP: '1' diff --git a/tests/case/workbench/result/apply/image-generation-preview-and-revert.e2e.test.ts b/tests/case/workbench/result/apply/image-generation-preview-and-revert.e2e.test.ts index 31d1bf3e..b25a1cc7 100644 --- a/tests/case/workbench/result/apply/image-generation-preview-and-revert.e2e.test.ts +++ b/tests/case/workbench/result/apply/image-generation-preview-and-revert.e2e.test.ts @@ -29,6 +29,7 @@ describe('workbench result e2e', () => { groups: ['apply'], description: '真实 AI 生成 PNG 后,验证图片预览、工具输入契约和新建文件回退。', fixtures: [], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/result/apply/image-generation-update-preview-and-revert.e2e.test.ts b/tests/case/workbench/result/apply/image-generation-update-preview-and-revert.e2e.test.ts index 91622e68..8c465c0b 100644 --- a/tests/case/workbench/result/apply/image-generation-update-preview-and-revert.e2e.test.ts +++ b/tests/case/workbench/result/apply/image-generation-update-preview-and-revert.e2e.test.ts @@ -32,6 +32,7 @@ describe('workbench result e2e', () => { groups: ['apply'], description: '真实 AI 更新已有 PNG 后,验证图片预览和回退恢复原始 bytes。', fixtures: [], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/result/apply/text-file-apply-preview-and-revert.e2e.test.ts b/tests/case/workbench/result/apply/text-file-apply-preview-and-revert.e2e.test.ts index be7f7094..959960e5 100644 --- a/tests/case/workbench/result/apply/text-file-apply-preview-and-revert.e2e.test.ts +++ b/tests/case/workbench/result/apply/text-file-apply-preview-and-revert.e2e.test.ts @@ -33,6 +33,7 @@ describe('workbench result e2e', () => { groups: ['apply'], description: '验证 Markdown 打开即编辑态、文本修改 diff 展示和按原始 bytes 回退。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', extraEnv: { NCURATOR_E2E_OPEN_WITH_NOOP: '1' diff --git a/tests/case/workbench/result/apply/wps-sync-dispatch-on-write-and-revert.e2e.test.ts b/tests/case/workbench/result/apply/wps-sync-dispatch-on-write-and-revert.e2e.test.ts index ce9efd3d..6a100fab 100644 --- a/tests/case/workbench/result/apply/wps-sync-dispatch-on-write-and-revert.e2e.test.ts +++ b/tests/case/workbench/result/apply/wps-sync-dispatch-on-write-and-revert.e2e.test.ts @@ -33,6 +33,7 @@ describe('workbench result e2e', () => { description: '真实 AI 写回并回退 docx / xlsx / pptx 后,馆长回环 /status 对各路径记录 open dispatch。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', // 独占:回环固定优先端口 18723,并行包测会抢端口导致 /status 读到别的实例。 execution: 'exclusive', diff --git a/tests/case/workbench/result/wps-office/wps-office-reload-on-write-and-revert.e2e.test.ts b/tests/case/workbench/result/wps-office/wps-office-reload-on-write-and-revert.e2e.test.ts index 13eb51f6..25f695b7 100644 --- a/tests/case/workbench/result/wps-office/wps-office-reload-on-write-and-revert.e2e.test.ts +++ b/tests/case/workbench/result/wps-office/wps-office-reload-on-write-and-revert.e2e.test.ts @@ -213,6 +213,7 @@ describe('workbench result e2e', () => { description: 'opt-in:本机已授权的 WPS 打开目标 docx / xlsx / pptx 后,AI 写回/回退应先 close 再写盘再 open,窗口正文出现标记并在回退后恢复。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'exclusive', windowMode: 'visible', @@ -424,6 +425,7 @@ describe('WPS audit navigation e2e', () => { groups: ['wps-office'], description: '真实审核区域点击冷启动和再次激活 WPS,并核对实际选区及不写盘边界。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'exclusive', windowMode: 'visible', diff --git a/tests/case/workbench/run/blocker/ai-question-flow.e2e.test.ts b/tests/case/workbench/run/blocker/ai-question-flow.e2e.test.ts index 60b044c8..43023126 100644 --- a/tests/case/workbench/run/blocker/ai-question-flow.e2e.test.ts +++ b/tests/case/workbench/run/blocker/ai-question-flow.e2e.test.ts @@ -37,6 +37,7 @@ describe('workbench AI question e2e', () => { groups: ['blocker'], description: '真实 Workbench 提问卡片、伙伴切换与刷新恢复、提交后 Activity 问答投影。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/run/blocker/approval-batch-flow.e2e.test.ts b/tests/case/workbench/run/blocker/approval-batch-flow.e2e.test.ts index 4ff48701..79e23238 100644 --- a/tests/case/workbench/run/blocker/approval-batch-flow.e2e.test.ts +++ b/tests/case/workbench/run/blocker/approval-batch-flow.e2e.test.ts @@ -922,6 +922,7 @@ describe('workbench run e2e', () => { description: '同一真实 Workbench E2E case 通过多轮运行验证只读查询、终端审核、审批卡决策和完全访问流程。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/run/blocker/cancelled-run-keeps-artifact.e2e.test.ts b/tests/case/workbench/run/blocker/cancelled-run-keeps-artifact.e2e.test.ts index 34f3d902..f9a9102c 100644 --- a/tests/case/workbench/run/blocker/cancelled-run-keeps-artifact.e2e.test.ts +++ b/tests/case/workbench/run/blocker/cancelled-run-keeps-artifact.e2e.test.ts @@ -29,6 +29,7 @@ describe('workbench run e2e', () => { groups: ['blocker'], description: '文件修改落地后取消运行,仍保留文件卡片和审核模式。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/run/blocker/stop-running-or-approval-run.e2e.test.ts b/tests/case/workbench/run/blocker/stop-running-or-approval-run.e2e.test.ts index 0105d112..c810bf3e 100644 --- a/tests/case/workbench/run/blocker/stop-running-or-approval-run.e2e.test.ts +++ b/tests/case/workbench/run/blocker/stop-running-or-approval-run.e2e.test.ts @@ -193,6 +193,7 @@ describe('workbench run e2e', () => { groups: ['blocker'], description: '真实长任务和真实审批等待中点击停止,验证运行结束和 UI 恢复。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/run/browser/browser-navigate-cnipa-https.e2e.test.ts b/tests/case/workbench/run/browser/browser-navigate-cnipa-https.e2e.test.ts index e8f6f497..ff305a5e 100644 --- a/tests/case/workbench/run/browser/browser-navigate-cnipa-https.e2e.test.ts +++ b/tests/case/workbench/run/browser/browser-navigate-cnipa-https.e2e.test.ts @@ -25,6 +25,7 @@ describe('workbench browser cnipa e2e', () => { description: '公网专利公布公告明确 https 不改 http;打不开或超时时停在馆长统一错误页,而不是白屏。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'exclusive', windowMode: 'visible', diff --git a/tests/case/workbench/run/browser/browser-navigate-error-page.e2e.test.ts b/tests/case/workbench/run/browser/browser-navigate-error-page.e2e.test.ts index fa0c1bef..21304083 100644 --- a/tests/case/workbench/run/browser/browser-navigate-error-page.e2e.test.ts +++ b/tests/case/workbench/run/browser/browser-navigate-error-page.e2e.test.ts @@ -71,6 +71,7 @@ describe('workbench browser navigate e2e', () => { description: '本地页验证输入什么协议就打开什么:裸主机只补 https 失败不改 http,明确 http 能打开,明确 https 失败停在馆长统一错误页并展示 Chromium 错误码,加载中可见地址栏底线,AI navigate 成功/失败都带回 tabId。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'exclusive', windowMode: 'visible', diff --git a/tests/case/workbench/run/browser/browser-use-task-panel.e2e.test.ts b/tests/case/workbench/run/browser/browser-use-task-panel.e2e.test.ts index ef5d356b..2eb0299f 100644 --- a/tests/case/workbench/run/browser/browser-use-task-panel.e2e.test.ts +++ b/tests/case/workbench/run/browser/browser-use-task-panel.e2e.test.ts @@ -279,6 +279,7 @@ describe('workbench run e2e', () => { description: '真实 AI 使用 browser_use 操作本地网页(无进程级调试口),验证右侧浏览器面板、网页结果和最终回答闭环。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/run/browser/browser-view-lifecycle.e2e.test.ts b/tests/case/workbench/run/browser/browser-view-lifecycle.e2e.test.ts index 1613067a..6cefd28d 100644 --- a/tests/case/workbench/run/browser/browser-view-lifecycle.e2e.test.ts +++ b/tests/case/workbench/run/browser/browser-view-lifecycle.e2e.test.ts @@ -67,6 +67,7 @@ describe('workbench browser lifecycle e2e', () => { description: '使用真实窗口像素验证浏览器初次显示、折叠恢复、原生菜单覆盖和会话切换期间持续运行。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'exclusive', windowMode: 'visible', diff --git a/tests/case/workbench/run/persistence/interrupted-task-resume-flow.e2e.test.ts b/tests/case/workbench/run/persistence/interrupted-task-resume-flow.e2e.test.ts index 2ad0f190..44675e35 100644 --- a/tests/case/workbench/run/persistence/interrupted-task-resume-flow.e2e.test.ts +++ b/tests/case/workbench/run/persistence/interrupted-task-resume-flow.e2e.test.ts @@ -46,6 +46,7 @@ describe('workbench run e2e', () => { groups: ['persistence'], description: '真实模型触发一次 Graph 运行上限后,在同一会话继续并恢复原任务。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/run/persistence/run-navigation-snapshot-replay.e2e.test.ts b/tests/case/workbench/run/persistence/run-navigation-snapshot-replay.e2e.test.ts index 2ecd8d91..9fcafb11 100644 --- a/tests/case/workbench/run/persistence/run-navigation-snapshot-replay.e2e.test.ts +++ b/tests/case/workbench/run/persistence/run-navigation-snapshot-replay.e2e.test.ts @@ -32,6 +32,7 @@ describe('workbench run persistence e2e', () => { description: '真实 AI 输出过程中切到 Settings,完成后回到 Workbench,通过 session snapshot replay 恢复过程流和最终回答。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/run/persistence/session-toolset-memory-persistence.e2e.test.ts b/tests/case/workbench/run/persistence/session-toolset-memory-persistence.e2e.test.ts index 7623d2fd..84f384d2 100644 --- a/tests/case/workbench/run/persistence/session-toolset-memory-persistence.e2e.test.ts +++ b/tests/case/workbench/run/persistence/session-toolset-memory-persistence.e2e.test.ts @@ -33,6 +33,7 @@ describe('workbench run e2e', () => { description: '真实 Workbench 同一会话的多轮 AI run 应共享已加载的 Word 工具组,后续 run 不需要重新 toolset_loading。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/run/process/ai-process-stream-and-final-answer.e2e.test.ts b/tests/case/workbench/run/process/ai-process-stream-and-final-answer.e2e.test.ts index 47a97765..50e4dfff 100644 --- a/tests/case/workbench/run/process/ai-process-stream-and-final-answer.e2e.test.ts +++ b/tests/case/workbench/run/process/ai-process-stream-and-final-answer.e2e.test.ts @@ -230,6 +230,7 @@ describe('workbench run e2e', () => { description: '真实 AI 总结 workspace 事实,验证 Thought/Activity 过程层渲染、工具平铺、header 规则与最终回答收口。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/run/process/queue-message-during-run.e2e.test.ts b/tests/case/workbench/run/process/queue-message-during-run.e2e.test.ts index 1048f6fd..90c80539 100644 --- a/tests/case/workbench/run/process/queue-message-during-run.e2e.test.ts +++ b/tests/case/workbench/run/process/queue-message-during-run.e2e.test.ts @@ -37,6 +37,7 @@ describe('workbench run e2e', () => { description: '真实模型读取资料过程中追加两条消息,验证单一主按钮切换、浅灰待处理态及安全边界后的 FIFO 续接。', fixtures: [], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/run/process/terminal-background-service-flow.e2e.test.ts b/tests/case/workbench/run/process/terminal-background-service-flow.e2e.test.ts index c49957eb..fd29dd16 100644 --- a/tests/case/workbench/run/process/terminal-background-service-flow.e2e.test.ts +++ b/tests/case/workbench/run/process/terminal-background-service-flow.e2e.test.ts @@ -44,6 +44,7 @@ describe('workbench terminal e2e', () => { groups: ['process'], description: '真实 AI 启动持久终端 HTTP 服务、让出控制权并用浏览器验证页面。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/run/process/terminal-unicode-file-list.e2e.test.ts b/tests/case/workbench/run/process/terminal-unicode-file-list.e2e.test.ts index 7b273542..8e9a9bde 100644 --- a/tests/case/workbench/run/process/terminal-unicode-file-list.e2e.test.ts +++ b/tests/case/workbench/run/process/terminal-unicode-file-list.e2e.test.ts @@ -33,6 +33,7 @@ describe('workbench terminal unicode e2e', () => { groups: ['process'], description: '真实 Electron 与真实 AI 通过终端读取中文目录和中文文件名。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/run/subagent/subagent-delegation-browser-sidecar.e2e.test.ts b/tests/case/workbench/run/subagent/subagent-delegation-browser-sidecar.e2e.test.ts index db70da34..a899c2c6 100644 --- a/tests/case/workbench/run/subagent/subagent-delegation-browser-sidecar.e2e.test.ts +++ b/tests/case/workbench/run/subagent/subagent-delegation-browser-sidecar.e2e.test.ts @@ -184,6 +184,7 @@ describe('subagent browser and sidecar real UI', () => { groups: ['subagent'], description: '子任务独立标签、sidecar、头像气泡与统一回退、停止与崩溃续聊', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', @@ -914,6 +915,7 @@ describe('subagent browser and sidecar real UI', () => { groups: ['subagent'], description: '派发只显示助手,失败云保留真实错误并支持刷新恢复', fixtures: [], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/scheduled-task/scheduled-task-ai-create.e2e.test.ts b/tests/case/workbench/scheduled-task/scheduled-task-ai-create.e2e.test.ts index d0e19484..9896fbfb 100644 --- a/tests/case/workbench/scheduled-task/scheduled-task-ai-create.e2e.test.ts +++ b/tests/case/workbench/scheduled-task/scheduled-task-ai-create.e2e.test.ts @@ -32,6 +32,7 @@ describe('workbench scheduled-task e2e', () => { groups: [], description: '验证 AI 创建伙伴列表最多显示四项后滚动、名称截断、选择状态与草稿切换。', fixtures: [], + knowledge: 'cached', serviceMode: 'real', windowMode: 'visible', runMode: 'single', diff --git a/tests/case/workbench/scheduled-task/scheduled-task-config-lifecycle.e2e.test.ts b/tests/case/workbench/scheduled-task/scheduled-task-config-lifecycle.e2e.test.ts index a4c6b5e9..63245a4c 100644 --- a/tests/case/workbench/scheduled-task/scheduled-task-config-lifecycle.e2e.test.ts +++ b/tests/case/workbench/scheduled-task/scheduled-task-config-lifecycle.e2e.test.ts @@ -46,6 +46,7 @@ describe('workbench scheduled-task e2e', () => { description: '验证定时任务创建和编辑抽屉的标题关闭、未保存确认、字段顺序、描述滚动、伙伴列表及完整任务闭环。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', windowMode: 'visible', runMode: 'single', diff --git a/tests/case/workbench/scheduled-task/scheduled-task-description-reference.e2e.test.ts b/tests/case/workbench/scheduled-task/scheduled-task-description-reference.e2e.test.ts index b6d772dc..54d19bb0 100644 --- a/tests/case/workbench/scheduled-task/scheduled-task-description-reference.e2e.test.ts +++ b/tests/case/workbench/scheduled-task/scheduled-task-description-reference.e2e.test.ts @@ -52,6 +52,7 @@ describe('workbench scheduled-task e2e', () => { description: '验证通用 Composer 输入框中的行内 @ 引用可保存、回填并在立即执行时传给伙伴消息。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', windowMode: 'visible', execution: 'live', diff --git a/tests/case/workbench/scheduled-task/scheduled-task-partner-deletion.e2e.test.ts b/tests/case/workbench/scheduled-task/scheduled-task-partner-deletion.e2e.test.ts index 1ae04090..994d2e82 100644 --- a/tests/case/workbench/scheduled-task/scheduled-task-partner-deletion.e2e.test.ts +++ b/tests/case/workbench/scheduled-task/scheduled-task-partner-deletion.e2e.test.ts @@ -31,6 +31,7 @@ describe('workbench scheduled-task e2e', () => { groups: [], description: '验证删除伙伴后任务保留、解绑停用,且可编辑重新绑定。', fixtures: [], + knowledge: 'cached', serviceMode: 'real', windowMode: 'visible', runMode: 'single', diff --git a/tests/case/workbench/scheduled-task/scheduled-task-trigger-and-session-refresh.e2e.test.ts b/tests/case/workbench/scheduled-task/scheduled-task-trigger-and-session-refresh.e2e.test.ts index 3147c96b..12a9e29e 100644 --- a/tests/case/workbench/scheduled-task/scheduled-task-trigger-and-session-refresh.e2e.test.ts +++ b/tests/case/workbench/scheduled-task/scheduled-task-trigger-and-session-refresh.e2e.test.ts @@ -54,6 +54,7 @@ describe('workbench scheduled-task e2e', () => { groups: [], description: '创建未来分钟触发的定时任务后,向绑定伙伴追加消息并发送,不切换当前查看伙伴。', fixtures: ['tests/fixtures/workspace'], + knowledge: 'cached', serviceMode: 'real', execution: 'live', windowMode: 'visible', diff --git a/tests/case/workbench/skills/user-skill-use-live.e2e.test.ts b/tests/case/workbench/skills/user-skill-use-live.e2e.test.ts index 5bebae1f..bc9524b9 100644 --- a/tests/case/workbench/skills/user-skill-use-live.e2e.test.ts +++ b/tests/case/workbench/skills/user-skill-use-live.e2e.test.ts @@ -82,6 +82,10 @@ describe('workbench skills live', () => { { page, blockerPolicy: 'auto_approve' } ) recordWorkbenchAiEvidence(runtime, first, 1) + const firstContent = await fs.readFile( + path.join(setup.workspace.rootPath, 'report-one.json'), + 'utf8' + ) await openSkills(page) await page.getByTestId(`${selectors.editPrefix}-${skill.id}`).click() const body = await page.getByTestId(selectors.content).inputValue() @@ -96,7 +100,7 @@ describe('workbench skills live', () => { const secondAt = Date.now() await sendWorkbenchMessage( page, - `继续按周报归档规范另存 ${path.join(setup.workspace.rootPath, 'report-two.json')}。事实:完成方案初稿;外部联调仍在等待;下周确认排期。` + `继续按周报归档规范另存 ${path.join(setup.workspace.rootPath, 'report-two.json')},保留 report-one.json 原文件内容不变。事实:完成方案初稿;外部联调仍在等待;下周确认排期。` ) const second = await waitForWorkbenchAiRunCompletion( runtime, @@ -117,6 +121,9 @@ describe('workbench skills live', () => { return { reportOne, reportTwo, + firstContentPreserved: + firstContent === + (await fs.readFile(path.join(setup.workspace.rootPath, 'report-one.json'), 'utf8')), loaded: first.events.some( (event) => event.type === 'tool_call_started' && event.toolName === 'load_skill' ), @@ -137,7 +144,8 @@ describe('workbench skills live', () => { }, assert: async (result, runtime) => { await runtime.assertThat( - result.loaded && + result.firstContentPreserved && + result.loaded && result.originalPrompt && result.updatedPrompt && result.resourceRead && diff --git a/tests/fixtures/skills/customer-weekly/SKILL.md b/tests/fixtures/skills/customer-weekly/SKILL.md new file mode 100644 index 00000000..f6bf103d --- /dev/null +++ b/tests/fixtures/skills/customer-weekly/SKILL.md @@ -0,0 +1,12 @@ +--- +name: customer-weekly +description: 根据多份项目资料撰写客户周报、核对对外口径并保留依据时使用。 +--- +# 客户周报规范 + +先阅读相对资源 references/editorial-policy.md,再整理业务资料。 +输出 Markdown,使用「整体判断」「关键进展」「风险与依赖」「负责人及下一步」「依据」五节,先结论后解释。 +上周已发版仅供结构和语气参考,不把旧事实当成本周进展。 +遇到资料冲突,按编辑规范说明采信依据;内部随手判断不能替代正式确认。 +负责人和执行动作分开表述。未明确的日期、金额和完成情况不要补写。 +「依据」用相对文件链接标注关键判断来自哪份资料;不得修改业务源文件。 diff --git a/tests/fixtures/skills/customer-weekly/references/editorial-policy.md b/tests/fixtures/skills/customer-weekly/references/editorial-policy.md new file mode 100644 index 00000000..98e2b2c9 --- /dev/null +++ b/tests/fixtures/skills/customer-weekly/references/editorial-policy.md @@ -0,0 +1,9 @@ +# 客户外发编辑约定 + +归档口径:客户同步稿 / CW-2026。 + +当前资料发生冲突时,法务复核及已确认对外口径优先于站会、临时备注和历史周报。 +最新补充事实可以更新具体进展,但不自动构成合规签署完成的证据。 +正面进展与尚待闭环事项都要保留,不把“预计”“协调中”升级为已完成。 +开头明确风险判断,正文简洁、克制且可直接供客户审阅;内部责任分工不要混成多个对外主负责人。 +来源链接应指向工作区原件,不指向技能规范或不存在的文件。 diff --git a/tests/fixtures/skills/meeting-minutes/SKILL.md b/tests/fixtures/skills/meeting-minutes/SKILL.md new file mode 100644 index 00000000..e0b0fd16 --- /dev/null +++ b/tests/fixtures/skills/meeting-minutes/SKILL.md @@ -0,0 +1,7 @@ +--- +name: meeting-minutes +description: 把用户提供的会议逐字稿整理为出席人、议题、讨论和决议纪要时使用。 +--- +# 会议纪要规范 + +只有整理会议逐字稿时使用。按出席人、议题、讨论、决议组织,不推测未提供的决议。 diff --git a/tests/fixtures/skills/risk-actions/SKILL.md b/tests/fixtures/skills/risk-actions/SKILL.md new file mode 100644 index 00000000..cdb88e60 --- /dev/null +++ b/tests/fixtures/skills/risk-actions/SKILL.md @@ -0,0 +1,11 @@ +--- +name: risk-actions +description: 把项目风险转成可跟踪的责任分工、后续行动和闭环条件清单时使用,可配合客户周报。 +--- +# 风险行动清单规范 + +先阅读相对资源 references/action-columns.md。 +输出 UTF-8 CSV,按资源规定的列顺序组织,含表头;遇到逗号或换行用标准 CSV 引号转义。 +每行围绕一个可跟踪事项,区分对外主负责人、具体执行人和复核职责,写清动作、时间窗口、闭环条件及依据。 +未提供的具体截止日期不可推测,可使用来源中的相对时间窗口;缺少责任人写「待确认」。 +周报与清单必须使用相同事实口径。完成一项补件不代表整项合规风险关闭。 diff --git a/tests/fixtures/skills/risk-actions/references/action-columns.md b/tests/fixtures/skills/risk-actions/references/action-columns.md new file mode 100644 index 00000000..137b6d37 --- /dev/null +++ b/tests/fixtures/skills/risk-actions/references/action-columns.md @@ -0,0 +1,7 @@ +# 行动清单列定义 + +列顺序:事项,当前状态,对外主负责人,执行人,下一步,时间窗口,闭环条件,依据 + +「依据」填写工作区原始资料的相对路径;多份依据用分号分隔。 +「当前状态」使用「待处理」「进行中」「待复核」「已完成」之一,按证据选择。 +同一行不要把不同角色全写进「对外主负责人」。闭环条件应能验证,不能只写「持续关注」。 diff --git a/tests/harness/backend/artifactCollector.ts b/tests/harness/backend/artifactCollector.ts index c896f6d9..2daf98b2 100644 --- a/tests/harness/backend/artifactCollector.ts +++ b/tests/harness/backend/artifactCollector.ts @@ -27,6 +27,7 @@ export const collectBackendRunArtifacts = async (input: { runId: string workspaceRoot: string runtimeEventsPath: string + outputFiles?: readonly string[] fileChangeRecords?: readonly AiFileChangeRecord[] }): Promise => { const artifactsDir = resolveRunArtifactsDir({ @@ -89,5 +90,24 @@ export const collectBackendRunArtifacts = async (input: { } } + for (const relative of input.outputFiles || []) { + const source = path.resolve(input.workspaceRoot, relative) + const within = path.relative(input.workspaceRoot, source) + if (within.startsWith('..') || path.isAbsolute(within)) + throw new Error(`Output escapes workspace: ${relative}`) + const target = path.join(artifactsDir, 'outputs', within) + try { + await mkdir(path.dirname(target), { recursive: true }) + await cp(source, target) + collected.push({ + id: `output-${relative}`, + kind: 'other', + path: path.posix.join('artifacts', 'outputs', within.split(path.sep).join('/')), + fileName: path.basename(relative) + }) + } catch { + /* failed runs still archive every output that exists */ + } + } return collected } diff --git a/tests/harness/backend/run-backend-case.ts b/tests/harness/backend/run-backend-case.ts index 547c3a07..6671bca8 100644 --- a/tests/harness/backend/run-backend-case.ts +++ b/tests/harness/backend/run-backend-case.ts @@ -122,6 +122,8 @@ const buildStats = ( export const runBackendCase = async ( definition: TestCaseDefinition ): Promise => { + const originalCacheEnabled = process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE + process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE = definition.knowledge === 'cached' ? '1' : '0' const liveConfig = definition.harnessMode === 'live' ? loadLiveLlmConfigFromEnv() : null const runtimeModelName = liveConfig?.model_name || definition.runtimeModelName || null const startedAtMs = Date.now() @@ -209,7 +211,8 @@ export const runBackendCase = async ( data: { description: definition.description, fixtures: definition.fixtures.map((fixturePath) => path.resolve(fixturePath)), - runMode: definition.runMode + runMode: definition.runMode, + knowledge: definition.knowledge || 'none' } }) @@ -279,7 +282,8 @@ export const runBackendCase = async ( runId, workspaceRoot, runtimeEventsPath: streamingLogWriter.paths.runtimeEventsPath, - fileChangeRecords + fileChangeRecords, + outputFiles: definition.outputFiles }) : [] const emptySeedManifest: BackendSeedManifest = { @@ -332,5 +336,7 @@ export const runBackendCase = async ( if (workspaceRoot) { await cleanupWorkspace(workspaceRoot) } + if (originalCacheEnabled === undefined) delete process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE + else process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE = originalCacheEnabled } } diff --git a/tests/harness/backend/test-types.ts b/tests/harness/backend/test-types.ts index 826c4716..056ffd6c 100644 --- a/tests/harness/backend/test-types.ts +++ b/tests/harness/backend/test-types.ts @@ -252,12 +252,14 @@ export type BackendTestRuntime = { export type TestCaseDefinition = { caseId: string harnessMode: BackendHarnessMode + knowledge?: 'none' | 'cached' | 'cold' testType?: Extract domain: Extract suite: string groups?: readonly string[] description: string fixtures: readonly string[] + outputFiles?: readonly string[] runMode: TestRunMode runtimeModelName?: string | null execute: (runtime: BackendTestRuntime) => Promise diff --git a/tests/harness/backend/workspaceKbSeed.ts b/tests/harness/backend/workspaceKbSeed.ts index 63726352..b59262c2 100644 --- a/tests/harness/backend/workspaceKbSeed.ts +++ b/tests/harness/backend/workspaceKbSeed.ts @@ -1,18 +1,10 @@ import { createHash } from 'node:crypto' -import { access, mkdir, readFile, writeFile } from 'node:fs/promises' +import { access } from 'node:fs/promises' import path from 'node:path' -import { - SPLITTER_BIG_CHUNK_SETTING, - SPLITTER_MINI_CHUNK_SIZE -} from '../../../src/main/utils/constant' +import { createWorkspaceKnowledgeCache } from '../../../src/main/services/workspace/WorkspaceKnowledgeCache' import { WorkspaceKnowledgePrepareService } from '../../../src/main/services/workspace/WorkspaceKnowledgePrepareService' import { WorkspaceSourceService } from '../../../src/main/services/workspace/WorkspaceSourceService' import { prepareWorkspaceSource } from '../../../src/main/services/workspace/worker/workspacePrepareWorker' -import type { - WorkspaceKnowledgeCache, - WorkspaceKnowledgeSplitSnapshot, - WorkspaceKnowledgeVectorSnapshot -} from '../../../src/main/services/workspace/types' import type { BackendTestRuntime, SeedKbDocumentInput, @@ -20,31 +12,8 @@ import type { WorkspaceKbSeedResult } from './test-types' -const WORKSPACE_KB_CACHE_SCHEMA_VERSION = 1 -const WORKSPACE_KB_CACHE_ROOT = path.resolve( - process.cwd(), - 'tests', - '.cache', - 'kb-fixtures', - 'workspace' -) const TEST_WORKSPACE_ID = 'test-workspace' -type PersistedWorkspaceSplitCache = { - schemaVersion: number - splitter: { - big: typeof SPLITTER_BIG_CHUNK_SETTING - mini: typeof SPLITTER_MINI_CHUNK_SIZE - } - snapshot: WorkspaceKnowledgeSplitSnapshot -} - -type PersistedWorkspaceVectorCache = { - schemaVersion: number - splitCacheKey: string - snapshot: WorkspaceKnowledgeVectorSnapshot -} - type WorkspaceKbSeedExecutionResult = { results: WorkspaceKbSeedResult[] kbDocuments: SeedKbDocumentInput[] @@ -63,132 +32,6 @@ const normalizeRelativePath = (value: string): string => const toDocumentId = (relativePath: string): string => `workspace-file-${createHash('sha1').update(normalizeRelativePath(relativePath)).digest('hex')}` -const buildSplitCacheKey = (input: { contentHash: string; fileExt: string }): string => - createHash('sha256') - .update( - JSON.stringify({ - schemaVersion: WORKSPACE_KB_CACHE_SCHEMA_VERSION, - contentHash: input.contentHash, - fileExt: input.fileExt, - splitter: { - big: SPLITTER_BIG_CHUNK_SETTING, - mini: SPLITTER_MINI_CHUNK_SIZE - } - }) - ) - .digest('hex') - -const buildVectorCacheKey = (input: { splitCacheKey: string; modelName: string }): string => - createHash('sha256') - .update( - JSON.stringify({ - schemaVersion: WORKSPACE_KB_CACHE_SCHEMA_VERSION, - splitCacheKey: input.splitCacheKey, - modelName: input.modelName - }) - ) - .digest('hex') - -class WorkspaceKbFixtureCache implements WorkspaceKnowledgeCache { - public async loadSplitSnapshot(input: { - contentHash: string - fileExt: string - }): Promise { - const cacheFilePath = path.join( - WORKSPACE_KB_CACHE_ROOT, - 'splits', - `${buildSplitCacheKey(input)}.json` - ) - try { - await access(cacheFilePath) - } catch { - return null - } - - const file = JSON.parse(await readFile(cacheFilePath, 'utf8')) as PersistedWorkspaceSplitCache - if (file.schemaVersion !== WORKSPACE_KB_CACHE_SCHEMA_VERSION) { - return null - } - return file.snapshot - } - - public async saveSplitSnapshot(snapshot: WorkspaceKnowledgeSplitSnapshot): Promise { - const cacheFilePath = path.join( - WORKSPACE_KB_CACHE_ROOT, - 'splits', - `${buildSplitCacheKey({ - contentHash: snapshot.contentHash, - fileExt: snapshot.fileExt - })}.json` - ) - const payload: PersistedWorkspaceSplitCache = { - schemaVersion: WORKSPACE_KB_CACHE_SCHEMA_VERSION, - splitter: { - big: SPLITTER_BIG_CHUNK_SETTING, - mini: SPLITTER_MINI_CHUNK_SIZE - }, - snapshot - } - await mkdir(path.dirname(cacheFilePath), { recursive: true }) - await writeFile(cacheFilePath, JSON.stringify(payload), 'utf8') - } - - public async loadVectorSnapshot(input: { - contentHash: string - fileExt: string - modelName: string - }): Promise { - const splitCacheKey = buildSplitCacheKey({ - contentHash: input.contentHash, - fileExt: input.fileExt - }) - const cacheFilePath = path.join( - WORKSPACE_KB_CACHE_ROOT, - 'vectors', - `${buildVectorCacheKey({ - splitCacheKey, - modelName: input.modelName - })}.json` - ) - try { - await access(cacheFilePath) - } catch { - return null - } - - const file = JSON.parse(await readFile(cacheFilePath, 'utf8')) as PersistedWorkspaceVectorCache - if ( - file.schemaVersion !== WORKSPACE_KB_CACHE_SCHEMA_VERSION || - file.splitCacheKey !== splitCacheKey - ) { - return null - } - return file.snapshot - } - - public async saveVectorSnapshot(snapshot: WorkspaceKnowledgeVectorSnapshot): Promise { - const splitCacheKey = buildSplitCacheKey({ - contentHash: snapshot.contentHash, - fileExt: snapshot.fileExt - }) - const cacheFilePath = path.join( - WORKSPACE_KB_CACHE_ROOT, - 'vectors', - `${buildVectorCacheKey({ - splitCacheKey, - modelName: snapshot.modelName - })}.json` - ) - const payload: PersistedWorkspaceVectorCache = { - schemaVersion: WORKSPACE_KB_CACHE_SCHEMA_VERSION, - splitCacheKey, - snapshot - } - await mkdir(path.dirname(cacheFilePath), { recursive: true }) - await writeFile(cacheFilePath, JSON.stringify(payload), 'utf8') - } -} - const createSkippedResult = (input: { relativePath: string absolutePath: string @@ -216,7 +59,7 @@ export const seedWorkspaceKb = async (input: { params: WorkspaceKbSeedInput }): Promise => { const workspaceService = new WorkspaceSourceService() - const cache = new WorkspaceKbFixtureCache() + const cache = createWorkspaceKnowledgeCache() ?? undefined const prepareService = new WorkspaceKnowledgePrepareService({ embeddingService: input.runtime.embeddingService, documentsStore: input.runtime.documentsStore, diff --git a/tests/harness/e2e/run-e2e-case.ts b/tests/harness/e2e/run-e2e-case.ts index 87335c9a..c6a08aac 100644 --- a/tests/harness/e2e/run-e2e-case.ts +++ b/tests/harness/e2e/run-e2e-case.ts @@ -38,7 +38,7 @@ import { } from './resourceScope' export type E2ETestRunMode = 'single' | 'repeatable' -export type E2EExecutionClass = 'normal' | 'live' | 'exclusive' | 'warmup' +export type E2EExecutionClass = 'normal' | 'live' | 'exclusive' type KnowledgeCacheStats = { splitCacheHits: number @@ -162,6 +162,7 @@ export type E2ETestCaseDefinition = E2EServiceConfiguration & { groups?: readonly string[] description: string fixtures: readonly string[] + knowledge?: 'none' | 'cached' | 'cold' execution?: E2EExecutionClass windowMode?: ElectronWindowMode extraEnv?: Readonly> @@ -322,7 +323,13 @@ export const runE2ECase = async ( }) const resolvedExtraEnv = await resolveE2EModelEnv({ serviceMode: definition.serviceMode, - extraEnv: definition.extraEnv + extraEnv: { + ...definition.extraEnv, + NCURATOR_WORKSPACE_KNOWLEDGE_CACHE: + definition.knowledge === 'cached' && process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE === '1' + ? '1' + : '0' + } }) const events: TestLogEvent[] = [] const artifacts: TestArtifact[] = [] @@ -342,9 +349,7 @@ export const runE2ECase = async ( const knowledgeCacheStatsPath = String( process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE_STATS_PATH || '' ).trim() - const knowledgeCacheEnabled = - String(process.env.NCURATOR_WORKSPACE_KNOWLEDGE_CACHE || '').trim() === '1' && - Boolean(knowledgeCachePath) + const knowledgeCacheEnabled = definition.knowledge === 'cached' && Boolean(knowledgeCachePath) const knowledgeCacheBaseline = knowledgeCacheEnabled ? await readKnowledgeCacheStats(knowledgeCacheStatsPath || knowledgeCachePath) : { ...EMPTY_KNOWLEDGE_CACHE_STATS } @@ -648,6 +653,7 @@ export const runE2ECase = async ( fixtures: definition.fixtures.map((fixturePath) => path.resolve(fixturePath)), serviceMode: definition.serviceMode, execution: definition.execution || 'normal', + knowledge: definition.knowledge || 'none', fixtureReason: definition.fixtureReason || null, windowMode: definition.windowMode || 'background', runMode: definition.runMode diff --git a/tests/harness/e2e/settings.ts b/tests/harness/e2e/settings.ts index 8123061a..17e3285f 100644 --- a/tests/harness/e2e/settings.ts +++ b/tests/harness/e2e/settings.ts @@ -695,7 +695,9 @@ export const waitForFileIndexJobIdle = async ( } lastRunningJob = response.job if (Date.now() >= deadline) { - throw new Error('file index job did not become idle') + throw new Error( + `file index job did not become idle: ${response.job.processed_count}/${response.job.total_count}; ${response.job.message}; ${response.job.last_error || ''}` + ) } await page.waitForTimeout(500) }