dedup-queue.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. /**
  2. * Persistent serial queue for duplicate-merge operations.
  3. *
  4. * Why a queue (and not just kicking off `executeMerge` from the click
  5. * handler):
  6. * - Merges rewrite cross-references across the entire wiki. Two
  7. * concurrent merges race on the same files, last write wins, and
  8. * half the rewrites silently disappear.
  9. * - LLM calls take seconds; the user wants to queue several merges
  10. * and walk away. The queue must survive app close so an
  11. * interrupted merge resumes on next launch.
  12. *
  13. * Mirrors `ingest-queue.ts` almost line-for-line: same lifecycle
  14. * (pause / restore on project switch), same persistence file shape,
  15. * same retry-up-to-3 policy, same registry-based path resolution so
  16. * a relocated project still finds its tasks.
  17. */
  18. import { readFile, writeFile } from "@/commands/fs"
  19. import { useWikiStore } from "@/stores/wiki-store"
  20. import { normalizePath } from "@/lib/path-utils"
  21. import { getProjectPathById } from "@/lib/project-identity"
  22. import { hasUsableLlm } from "@/lib/has-usable-llm"
  23. import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
  24. import { executeMerge } from "@/lib/dedup-runner"
  25. import type { DuplicateGroup } from "@/lib/dedup"
  26. // ── Types ─────────────────────────────────────────────────────────────────
  27. export interface DedupTask {
  28. id: string
  29. projectId: string
  30. group: DuplicateGroup
  31. canonicalSlug: string
  32. modelId?: string
  33. status: "pending" | "processing" | "done" | "failed"
  34. addedAt: number
  35. error: string | null
  36. retryCount: number
  37. }
  38. // ── State ─────────────────────────────────────────────────────────────────
  39. let queue: DedupTask[] = []
  40. let processing = false
  41. let currentProjectId = ""
  42. let currentProjectPath = ""
  43. let currentAbortController: AbortController | null = null
  44. type MergeCompleteListener = (task: DedupTask) => void
  45. const mergeCompleteListeners = new Set<MergeCompleteListener>()
  46. /** Fires once when a merge task finishes successfully and leaves the queue. */
  47. export function onDedupMergeComplete(listener: MergeCompleteListener): () => void {
  48. mergeCompleteListeners.add(listener)
  49. return () => mergeCompleteListeners.delete(listener)
  50. }
  51. function notifyMergeComplete(task: DedupTask): void {
  52. for (const listener of mergeCompleteListeners) {
  53. try {
  54. listener(task)
  55. } catch (err) {
  56. console.error("[Dedup Queue] mergeComplete listener failed:", err)
  57. }
  58. }
  59. }
  60. // ── Persistence ───────────────────────────────────────────────────────────
  61. function queueFilePath(projectPath: string): string {
  62. return `${normalizePath(projectPath)}/.qmai/dedup-queue.json`
  63. }
  64. async function saveQueue(projectPath: string): Promise<void> {
  65. try {
  66. const toSave = queue.filter((t) => t.status !== "done")
  67. await writeFile(queueFilePath(projectPath), JSON.stringify(toSave, null, 2))
  68. } catch {
  69. // non-critical
  70. }
  71. }
  72. async function loadQueue(
  73. projectPath: string,
  74. projectId: string,
  75. ): Promise<DedupTask[]> {
  76. try {
  77. const raw = await readFile(queueFilePath(projectPath))
  78. const tasks = JSON.parse(raw) as DedupTask[]
  79. return tasks.map((t) => ({
  80. ...t,
  81. projectId: t.projectId ?? projectId,
  82. }))
  83. } catch {
  84. return []
  85. }
  86. }
  87. // ── Queue Operations ──────────────────────────────────────────────────────
  88. function generateId(): string {
  89. return `dedup-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
  90. }
  91. /**
  92. * Stable key for matching a queued task to a UI card. Order-independent
  93. * lowercase join — same shape used by dedup-storage's canonical key.
  94. */
  95. export function groupKey(slugs: readonly string[]): string {
  96. return [...slugs].map((s) => s.toLowerCase()).sort().join(",")
  97. }
  98. /**
  99. * Add a merge to the queue. The project MUST be the currently-active
  100. * project. Returns the new task's id. Idempotent on the same group:
  101. * if there's already a pending/processing/failed task for the same
  102. * slug-set, the existing id is returned instead of a duplicate.
  103. */
  104. export async function enqueueMerge(
  105. projectId: string,
  106. group: DuplicateGroup,
  107. canonicalSlug: string,
  108. modelId?: string,
  109. ): Promise<string> {
  110. const active = useWikiStore.getState().project
  111. if (!active || active.id !== projectId) {
  112. throw new Error(
  113. `enqueueMerge: project ${projectId} is not the active project (current: ${active?.id || "<none>"})`,
  114. )
  115. }
  116. await ensureQueueActive(active.id, active.path)
  117. if (!currentProjectId || currentProjectId !== projectId) {
  118. throw new Error(
  119. `enqueueMerge: failed to activate dedup queue for project ${projectId}`,
  120. )
  121. }
  122. const key = groupKey(group.slugs)
  123. const existing = queue.find(
  124. (t) =>
  125. t.projectId === projectId &&
  126. t.status !== "done" &&
  127. groupKey(t.group.slugs) === key,
  128. )
  129. if (existing) return existing.id
  130. const task: DedupTask = {
  131. id: generateId(),
  132. projectId,
  133. group,
  134. canonicalSlug,
  135. modelId: modelId?.trim() || undefined,
  136. status: "pending",
  137. addedAt: Date.now(),
  138. error: null,
  139. retryCount: 0,
  140. }
  141. queue.push(task)
  142. await saveQueue(currentProjectPath)
  143. processNext(currentProjectId)
  144. return task.id
  145. }
  146. /**
  147. * Reset a failed task back to pending so it gets another shot. Clears
  148. * the error and resets retryCount so the user gets the full 3
  149. * attempts again.
  150. */
  151. export async function retryTask(taskId: string): Promise<void> {
  152. let task = queue.find((t) => t.id === taskId)
  153. if (!task) return
  154. const projectId = task.projectId
  155. const active = useWikiStore.getState().project
  156. if (!active || active.id !== projectId) return
  157. await ensureQueueActive(active.id, active.path)
  158. task = queue.find((t) => t.id === taskId)
  159. if (!task || task.projectId !== currentProjectId) return
  160. task.status = "pending"
  161. task.error = null
  162. task.retryCount = 0
  163. await saveQueue(currentProjectPath)
  164. processNext(currentProjectId)
  165. }
  166. /**
  167. * Cancel/delete a task. If it's currently running, abort the LLM call
  168. * first — the merge writes will be left where they were when the
  169. * abort fired. Backup snapshots already on disk are kept either way.
  170. */
  171. export async function cancelTask(taskId: string): Promise<void> {
  172. let task = queue.find((t) => t.id === taskId)
  173. if (!task) return
  174. const projectId = task.projectId
  175. const active = useWikiStore.getState().project
  176. if (!active || active.id !== projectId) return
  177. await ensureQueueActive(active.id, active.path)
  178. task = queue.find((t) => t.id === taskId)
  179. if (!task || task.projectId !== currentProjectId) return
  180. if (task.status === "processing") {
  181. if (currentAbortController) {
  182. currentAbortController.abort()
  183. currentAbortController = null
  184. }
  185. processing = false
  186. }
  187. queue = queue.filter((t) => t.id !== taskId)
  188. await saveQueue(currentProjectPath)
  189. processNext(currentProjectId)
  190. }
  191. export function getQueue(): readonly DedupTask[] {
  192. return queue
  193. }
  194. export function getQueueSummary(): {
  195. pending: number
  196. processing: number
  197. failed: number
  198. total: number
  199. } {
  200. return {
  201. pending: queue.filter((t) => t.status === "pending").length,
  202. processing: queue.filter((t) => t.status === "processing").length,
  203. failed: queue.filter((t) => t.status === "failed").length,
  204. total: queue.length,
  205. }
  206. }
  207. /**
  208. * Test-only: wipe in-memory state without touching disk. Production
  209. * code should always use `pauseQueue()` so pending state lands in
  210. * the right project's file before the slate is cleared.
  211. */
  212. export function clearQueueState(): void {
  213. if (currentAbortController) {
  214. currentAbortController.abort()
  215. }
  216. queue = []
  217. processing = false
  218. currentProjectId = ""
  219. currentProjectPath = ""
  220. currentAbortController = null
  221. }
  222. /**
  223. * Project-switch handshake: flush the active project's queue to disk
  224. * (reverting any in-flight task to pending so it gets re-tried on
  225. * resume), then clear in-memory state.
  226. */
  227. export async function pauseQueue(): Promise<void> {
  228. if (!currentProjectId || !currentProjectPath) return
  229. const pausedProjectPath = currentProjectPath
  230. if (currentAbortController) {
  231. currentAbortController.abort()
  232. currentAbortController = null
  233. }
  234. processing = false
  235. for (const task of queue) {
  236. if (task.status === "processing") {
  237. task.status = "pending"
  238. }
  239. }
  240. await saveQueue(pausedProjectPath)
  241. queue = []
  242. currentProjectId = ""
  243. currentProjectPath = ""
  244. }
  245. /**
  246. * Ensure the in-memory dedup queue is bound to the given project.
  247. * No-op when already active; otherwise loads from disk via restoreQueue.
  248. */
  249. export async function ensureQueueActive(
  250. projectId: string,
  251. projectPath: string,
  252. ): Promise<void> {
  253. const pp = normalizePath(projectPath)
  254. if (currentProjectId === projectId && currentProjectPath === pp) return
  255. await restoreQueue(projectId, projectPath)
  256. }
  257. /**
  258. * Load a project's queue from disk and resume processing. Tasks left
  259. * in "processing" by an abrupt exit get reverted to "pending" so they
  260. * pick up on next process tick.
  261. */
  262. export async function restoreQueue(
  263. projectId: string,
  264. projectPath: string,
  265. ): Promise<void> {
  266. const pp = normalizePath(projectPath)
  267. queue = []
  268. processing = false
  269. currentAbortController = null
  270. currentProjectId = projectId
  271. currentProjectPath = pp
  272. const saved = await loadQueue(pp, projectId)
  273. if (saved.length === 0) return
  274. const mine = saved.filter((t) => t.projectId === projectId)
  275. if (mine.length !== saved.length) {
  276. console.warn(
  277. `[Dedup Queue] Dropped ${saved.length - mine.length} cross-project tasks during restore`,
  278. )
  279. }
  280. let restored = 0
  281. for (const task of mine) {
  282. if (task.status === "processing") {
  283. task.status = "pending"
  284. restored++
  285. }
  286. }
  287. queue = mine
  288. await saveQueue(pp)
  289. const pending = queue.filter((t) => t.status === "pending").length
  290. const failed = queue.filter((t) => t.status === "failed").length
  291. if (pending > 0 || restored > 0) {
  292. console.log(
  293. `[Dedup Queue] Restored: ${pending} pending, ${failed} failed, ${restored} resumed from interrupted`,
  294. )
  295. processNext(projectId)
  296. }
  297. }
  298. // ── Processing ────────────────────────────────────────────────────────────
  299. const MAX_RETRIES = 3
  300. async function processNext(projectId: string): Promise<void> {
  301. if (processing) return
  302. if (currentProjectId !== projectId) return
  303. const next = queue.find(
  304. (t) => t.projectId === projectId && t.status === "pending",
  305. )
  306. if (!next) return
  307. const registryPath = await getProjectPathById(projectId)
  308. const pp = registryPath ? normalizePath(registryPath) : ""
  309. if (currentProjectId !== projectId) return
  310. if (!pp) {
  311. next.status = "failed"
  312. next.error = "项目未在注册表中找到(可能已被删除?)"
  313. await saveQueue(currentProjectPath)
  314. processNext(projectId)
  315. return
  316. }
  317. processing = true
  318. next.status = "processing"
  319. await saveQueue(pp)
  320. if (currentProjectId !== projectId) return
  321. const state = useWikiStore.getState()
  322. const llmConfig = next.modelId?.trim()
  323. ? resolveModelConfig(next.modelId, state.llmConfig, state.providerConfigs)
  324. : resolveDefaultModel(state.llmConfig)
  325. if (!hasUsableLlm(llmConfig, state.providerConfigs)) {
  326. next.status = "failed"
  327. next.error = "LLM 未配置,请在设置中配置大模型提供方"
  328. processing = false
  329. await saveQueue(pp)
  330. return
  331. }
  332. console.log(
  333. `[Dedup Queue] Processing: merge ${next.group.slugs.join(",")} → ${next.canonicalSlug}`,
  334. )
  335. currentAbortController = new AbortController()
  336. try {
  337. await executeMerge(pp, next.group, next.canonicalSlug, llmConfig, {
  338. signal: currentAbortController.signal,
  339. })
  340. if (currentProjectId !== projectId) return
  341. currentAbortController = null
  342. const completedTask = { ...next }
  343. queue = queue.filter((t) => t.id !== next.id)
  344. await saveQueue(pp)
  345. // Tell the rest of the app the wiki tree changed.
  346. useWikiStore.getState().bumpDataVersion()
  347. console.log(`[Dedup Queue] Done: ${next.group.slugs.join(",")}`)
  348. notifyMergeComplete(completedTask)
  349. } catch (err) {
  350. if (currentProjectId !== projectId) return
  351. currentAbortController = null
  352. const message = err instanceof Error ? err.message : String(err)
  353. next.retryCount++
  354. next.error = message
  355. if (next.retryCount >= MAX_RETRIES) {
  356. next.status = "failed"
  357. console.log(
  358. `[Dedup Queue] Failed (${next.retryCount}x): ${next.group.slugs.join(",")} — ${message}`,
  359. )
  360. } else {
  361. next.status = "pending"
  362. console.log(
  363. `[Dedup Queue] Error (retry ${next.retryCount}/${MAX_RETRIES}): ${next.group.slugs.join(",")} — ${message}`,
  364. )
  365. }
  366. await saveQueue(pp)
  367. }
  368. processing = false
  369. processNext(projectId)
  370. }