index-manager.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as os from 'node:os'
  4. import * as path from 'node:path'
  5. import { execFileSync } from 'node:child_process'
  6. import { commitWithIsolatedIndex, onBookCommit, readIndexState, withBookWrite, writeBatchAtomic, type EmbeddingProvider } from '@webnovel/core'
  7. import { removeSync } from '../../core/src/repo/remove'
  8. import { BookIndexManager, type IndexBook } from '../src/indexing/manager'
  9. import { bookGitWatchPaths } from '../src/indexing/git'
  10. let workspace: string
  11. let book: IndexBook
  12. const managers: BookIndexManager[] = []
  13. beforeEach(() => {
  14. workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-index-manager-'))
  15. book = { root: path.join(workspace, '测试作品'), bookId: 'manager-book', name: '测试作品', workspace }
  16. put('作品契约/契约.md', '---\n书id: manager-book\n---\n测试作品')
  17. put('定稿/卷01/0001-开篇.md', '第一章原文')
  18. git('init', '--quiet')
  19. git('config', 'user.name', 'Index Test')
  20. git('config', 'user.email', 'index@example.invalid')
  21. git('config', 'commit.gpgsign', 'false')
  22. git('config', 'core.autocrlf', 'false')
  23. commit('initial')
  24. })
  25. afterEach(async () => {
  26. for (const manager of managers.splice(0)) await manager.close()
  27. removeSync(workspace)
  28. })
  29. const git = (...args: string[]) => execFileSync('git', args, { cwd: book.root, encoding: 'utf8', windowsHide: true })
  30. function put(relative: string, content: string) {
  31. const file = path.join(book.root, relative)
  32. fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, content)
  33. }
  34. function commit(message: string) { git('add', '.'); git('commit', '--quiet', '-m', message); return git('rev-parse', 'HEAD').trim() }
  35. function client(run: EmbeddingProvider['embed'] = async inputs => inputs.map(() => [1, 0])): EmbeddingProvider {
  36. return { metadata: { provider: 'fixture', model: 'fixture', dimensions: 2, revision: 'v1', batchSize: 1 }, embed: run, embedBatch: run }
  37. }
  38. function manager(provider: EmbeddingProvider, notify?: ConstructorParameters<typeof BookIndexManager>[0]['notify']) {
  39. const result = new BookIndexManager({ getProvider: () => provider, workspaces: () => [workspace], pollMs: 50, debounceMs: 5, retry: { maxRetries: 0 }, notify })
  40. managers.push(result); return result
  41. }
  42. async function ready(head = git('rev-parse', 'HEAD').trim()) {
  43. await vi.waitFor(() => expect(readIndexState(book.root)).toMatchObject({ phase: 'ready', indexedHead: head }), { timeout: 8000, interval: 30 })
  44. }
  45. function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done }); return { promise, resolve } }
  46. describe('Git 驱动的后台索引', () => {
  47. it('重启后发现新启用的场景配置;换模型和向量重建复用边界', async () => {
  48. const embedding = client()
  49. const old = manager(embedding)
  50. await old.control(book, 'enable', 'owner')
  51. await ready()
  52. await old.close()
  53. const segment = vi.fn(async (paragraphs: readonly string[]) => [paragraphs.length])
  54. let scenes = { metadata: { provider: 'fixture', model: 'scene', revision: 'v1' }, segment }
  55. const runtime = new BookIndexManager({ getProvider: () => embedding, getSceneProvider: () => scenes, workspaces: () => [workspace], pollMs: 50, debounceMs: 5 })
  56. managers.push(runtime)
  57. await runtime.refresh()
  58. await vi.waitFor(() => expect(readIndexState(book.root)).toMatchObject({ phase: 'ready', scenes: { completed: 1 } }), { timeout: 8000 })
  59. expect(segment).toHaveBeenCalledTimes(1)
  60. scenes = { ...scenes, metadata: { ...scenes.metadata, model: 'new-model', revision: 'v2' } }
  61. await runtime.refresh()
  62. await ready()
  63. expect(segment).toHaveBeenCalledTimes(1)
  64. await runtime.control(book, 'rebuild', 'owner')
  65. await ready()
  66. expect(segment).toHaveBeenCalledTimes(1)
  67. await runtime.control(book, 'rescan-scenes', 'owner', 1)
  68. await ready()
  69. expect(segment).toHaveBeenCalledTimes(2)
  70. }, 15_000)
  71. it('默认停用,启用后覆盖手工提交,设计提交不重复嵌入', async () => {
  72. const embed = vi.fn<EmbeddingProvider['embed']>(async inputs => inputs.map(() => [1, 0]))
  73. const runtime = manager(client(embed))
  74. await runtime.refresh()
  75. expect(embed).not.toHaveBeenCalled()
  76. await runtime.control(book, 'enable', 'owner')
  77. await ready()
  78. expect(embed).toHaveBeenCalledTimes(1)
  79. put('定稿/卷01/0002-新章.md', '第二章原文')
  80. const next = commit('external chapter')
  81. await ready(next)
  82. expect(embed).toHaveBeenCalledTimes(2)
  83. expect(embed.mock.calls[1]![0][0]!.text).toBe('第二章原文')
  84. put('大纲/故事骨架.md', '只是设计更新')
  85. const design = commit('design only')
  86. await ready(design)
  87. expect(embed).toHaveBeenCalledTimes(2)
  88. fs.unlinkSync(path.join(book.root, '定稿/卷01/0002-新章.md'))
  89. await ready(commit('delete chapter'))
  90. expect(readIndexState(book.root).chunks).toBe(1)
  91. expect(embed).toHaveBeenCalledTimes(2)
  92. }, 20_000)
  93. it('后台 API 挂起不妨碍 Git 提交,提交观察者故障不改变提交结果', async () => {
  94. const entered = deferred<void>(), response = deferred<readonly (readonly number[])[]>()
  95. const runtime = manager(client(async () => { entered.resolve(); return response.promise }))
  96. await runtime.control(book, 'enable', 'owner')
  97. await entered.promise
  98. const observed: string[][] = []
  99. const off = onBookCommit(event => { observed.push([...event.paths]); throw new Error('observer failure') })
  100. try {
  101. const result = withBookWrite(book.root, () => {
  102. writeBatchAtomic(book.root, [{ relPath: '大纲/故事骨架.md', content: '作者保存仍可完成' }])
  103. return commitWithIsolatedIndex(book.root, ['大纲/故事骨架.md'], 'design: while embedding')
  104. })
  105. expect(result.status).toBe(0)
  106. await Promise.resolve()
  107. expect(observed).toEqual([['大纲/故事骨架.md']])
  108. expect(fs.existsSync(path.join(book.root, '.webnovel/book.lock'))).toBe(false)
  109. } finally { off(); response.resolve([[1, 0]]) }
  110. await ready()
  111. }, 15_000)
  112. it('暂停和重启保留成功批次,不依赖主 Agent 存活', async () => {
  113. put('定稿/卷01/0002-次章.md', '第二章原文'); commit('second')
  114. const entered = deferred<void>(), response = deferred<readonly (readonly number[])[]>()
  115. let block = true, calls = 0
  116. const provider = client(async () => {
  117. calls++
  118. if (calls === 2 && block) { entered.resolve(); return response.promise }
  119. return [[1, 0]]
  120. })
  121. const original = manager(provider)
  122. await original.control(book, 'enable', 'owner')
  123. await entered.promise
  124. expect(readIndexState(book.root).completedChunks).toBe(1)
  125. await original.close()
  126. block = false
  127. response.resolve([[1, 0]])
  128. const restarted = manager(provider)
  129. await restarted.refresh()
  130. await ready()
  131. expect(readIndexState(book.root)).toMatchObject({ completedChunks: 2, reused: 1, generated: 1, recipientSession: 'owner' })
  132. expect(calls).toBe(3)
  133. await restarted.control(book, 'pause', 'owner')
  134. put('定稿/卷01/0003-末章.md', '第三章原文'); const newest = commit('while paused')
  135. await restarted.refresh()
  136. expect(readIndexState(book.root).phase).toBe('paused')
  137. expect(calls).toBe(3)
  138. await restarted.control(book, 'resume', 'owner')
  139. await ready(newest)
  140. expect(calls).toBe(4)
  141. }, 20_000)
  142. it('失败保留原因及收件会话,恢复收件人后只投递一次', async () => {
  143. let receiver = false
  144. const deliveries: string[] = []
  145. const runtime = manager(client(async () => { throw { code: 'http-error', httpStatus: 401 } }), (_book, notice) => {
  146. expect(notice.sessionId).toBe('owner-session')
  147. if (!receiver) return false
  148. deliveries.push(notice.id); return true
  149. })
  150. await runtime.control(book, 'enable', 'owner-session')
  151. await vi.waitFor(() => expect(readIndexState(book.root).phase).toBe('failed'))
  152. expect(readIndexState(book.root).notice?.error.message).toContain('401')
  153. expect(deliveries).toEqual([])
  154. receiver = true
  155. await runtime.refresh()
  156. await vi.waitFor(() => expect(readIndexState(book.root).notice?.delivered).toBe(true))
  157. await runtime.refresh()
  158. expect(deliveries).toHaveLength(1)
  159. }, 15_000)
  160. it('两个运行实例协调同一持久任务,不互相换代或重复请求', async () => {
  161. const entered = deferred<void>(), response = deferred<readonly (readonly number[])[]>()
  162. const embed = vi.fn<EmbeddingProvider['embed']>(async () => { entered.resolve(); return response.promise })
  163. const provider = client(embed)
  164. const first = manager(provider), second = manager(provider)
  165. await first.control(book, 'enable', 'owner')
  166. await entered.promise
  167. const generation = readIndexState(book.root).generation
  168. await second.refresh()
  169. await new Promise(resolve => setTimeout(resolve, 100))
  170. expect(readIndexState(book.root).generation).toBe(generation)
  171. expect(embed).toHaveBeenCalledTimes(1)
  172. response.resolve([[1, 0]])
  173. await ready()
  174. }, 15_000)
  175. it('worktree 的 git 文件与共享 refs 均能定位,分支切换刷新来源', async () => {
  176. const original = book
  177. const alternateWorkspace = path.join(workspace, 'linked')
  178. fs.mkdirSync(alternateWorkspace)
  179. const alternateRoot = path.join(alternateWorkspace, '分支作品')
  180. git('worktree', 'add', '--quiet', '-b', 'index-side', alternateRoot)
  181. book = { ...book, root: alternateRoot, workspace: alternateWorkspace }
  182. expect(fs.lstatSync(path.join(book.root, '.git')).isFile()).toBe(true)
  183. const watches = await bookGitWatchPaths(book.root)
  184. expect(watches.some(directory => directory.startsWith(path.join(original.root, '.git')))).toBe(true)
  185. const embed = vi.fn<EmbeddingProvider['embed']>(async inputs => inputs.map(() => [1, 0]))
  186. const runtime = manager(client(embed))
  187. await runtime.control(book, 'enable', 'owner')
  188. await ready()
  189. put('定稿/卷01/0001-开篇.md', '分支新正文'); const changed = commit('side edit')
  190. await ready(changed)
  191. git('checkout', '--quiet', '--detach', 'HEAD~1')
  192. await ready()
  193. expect(embed.mock.calls.some(call => call[0][0]?.text === '分支新正文')).toBe(true)
  194. await runtime.close()
  195. book = original
  196. expect(path.resolve(alternateRoot).startsWith(path.resolve(workspace) + path.sep)).toBe(true)
  197. git('worktree', 'remove', '--force', alternateRoot)
  198. }, 25_000)
  199. })