index.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. import { promises as fs } from 'node:fs'
  2. import path from 'node:path'
  3. import { ChapterWriter } from '../storage/adapters/ChapterWriter.js'
  4. import { ThreadLedgerWriter } from '../storage/adapters/ThreadLedgerWriter.js'
  5. import { EntityWriter } from '../storage/adapters/EntityWriter.js'
  6. import { TimelineWriter } from '../storage/adapters/TimelineWriter.js'
  7. import { SecretWriter } from '../storage/adapters/SecretWriter.js'
  8. import { SummaryWriter } from '../storage/adapters/SummaryWriter.js'
  9. import { createGit } from './git.js'
  10. import { refreshCacheAfterSourceChange } from '../cache/index.js'
  11. import { normalizeWorkspaceRel } from '../util/workspace-path.js'
  12. /**
  13. * 定稿:原子 commit(D3)。写工作树 → git add → commit → 最后清工作区。
  14. * 断电安全:commit 前任何中断都回滚未提交写入(仅 定稿/大纲,不碰工作区),
  15. * 工作区草稿原样保留,不存在半章入档。
  16. *
  17. * @param {{repoPath: string, cache?: object}} ctx
  18. * @param {object} payload 定稿包(章档案/正文/摘要/条目/设定变更/commit 行/待清工作区文件)
  19. * @param {{git?: object, faultAfterWrite?: boolean}} [opts] 注入 git(测试)/故障注入(断电模拟)
  20. * @returns {Promise<{ok: boolean, commitHash?: string, error?: string}>}
  21. */
  22. export async function finalizeChapter(ctx, payload, opts = {}) {
  23. const { repoPath } = ctx
  24. const git = opts.git || createGit(repoPath)
  25. const {
  26. chapterNum,
  27. frontMatter,
  28. body = '',
  29. summary = null,
  30. threadCreates = [],
  31. threadUpdates = [],
  32. characterUpdates = [],
  33. rosterUpserts = [],
  34. timelineRows = [],
  35. secretWrites = [],
  36. commitLines = {},
  37. workspaceFiles = [],
  38. } = payload
  39. // 1. 校验(不过则什么都没写,天然原样)
  40. if (!Number.isInteger(chapterNum)) return { ok: false, error: '章号必须是整数' }
  41. if (!frontMatter || !frontMatter.标题) return { ok: false, error: '缺少章档案或标题' }
  42. const stageFiles = []
  43. const rollbackFiles = []
  44. const chapterBackups = []
  45. let cacheRefresh = null
  46. try {
  47. // 2. 写工作树(全部落 定稿/大纲,非 工作区)
  48. const cw = await new ChapterWriter(repoPath).writeChapter(chapterNum, frontMatter, body, {
  49. preserveBackups: true,
  50. })
  51. if (!cw.ok) throw new Error(cw.error)
  52. stageFiles.push(cw.filePath, ...(cw.removedPaths || []))
  53. rollbackFiles.push(cw.filePath)
  54. chapterBackups.push(...(cw.backups || []))
  55. if (summary != null) {
  56. const sw = await new SummaryWriter(repoPath).writeChapterSummary(chapterNum, summary)
  57. if (!sw.ok) throw new Error(sw.error)
  58. stageFiles.push(sw.filePath)
  59. rollbackFiles.push(sw.filePath)
  60. }
  61. const tlw = new ThreadLedgerWriter(repoPath)
  62. // 新开条目先建档(开启类声明的入档执行体),再处理更新——同一章可"埋下并写首条履历"
  63. for (const t of threadCreates) {
  64. const r = await tlw.createThread(t)
  65. if (!r.ok) throw new Error(r.error)
  66. stageFiles.push(r.filePath)
  67. rollbackFiles.push(r.filePath)
  68. }
  69. for (const t of threadUpdates) {
  70. if (t.updates) {
  71. const r = await tlw.updateThread(t.id, t.updates)
  72. if (!r.ok) throw new Error(r.error)
  73. }
  74. if (t.history) {
  75. const r = await tlw.appendHistory(t.id, t.history)
  76. if (!r.ok) throw new Error(r.error)
  77. }
  78. const f = await tlw._findThreadFile(t.id)
  79. if (f) {
  80. stageFiles.push(f)
  81. rollbackFiles.push(f)
  82. }
  83. }
  84. const ew = new EntityWriter(repoPath)
  85. for (const c of characterUpdates) {
  86. const r = await ew.updateCharacter(c.name, c.updates)
  87. if (!r.ok) throw new Error(r.error)
  88. // 路径取写入器返回值(A10:文件名净化单源,防手拼路径与实际写点分裂)
  89. stageFiles.push(r.filePath)
  90. rollbackFiles.push(r.filePath)
  91. }
  92. for (const row of rosterUpserts) {
  93. const r = await ew.upsertRosterRow(row)
  94. if (!r.ok) throw new Error(r.error)
  95. const filePath = path.join(repoPath, '定稿', '设定', '名册.md')
  96. stageFiles.push(filePath)
  97. rollbackFiles.push(filePath)
  98. }
  99. const tw = new TimelineWriter(repoPath)
  100. for (const tr of timelineRows) {
  101. const r = await tw.appendRow(tr.volumeNum, tr.row)
  102. if (!r.ok) throw new Error(r.error)
  103. stageFiles.push(r.filePath)
  104. rollbackFiles.push(r.filePath)
  105. }
  106. const secw = new SecretWriter(repoPath)
  107. for (const s of secretWrites) {
  108. const r = await secw.write(s.id, s.frontMatter, s.content)
  109. if (!r.ok) throw new Error(r.error)
  110. stageFiles.push(r.filePath)
  111. rollbackFiles.push(r.filePath)
  112. }
  113. // 故障注入点(断电模拟,仅测试用)
  114. if (opts.faultAfterWrite) throw new Error('注入故障:写工作树后、commit 前中断')
  115. // 3. git add + commit(原子点)
  116. const relFiles = [...new Set(stageFiles)].map((f) => path.relative(repoPath, f))
  117. await git.add(relFiles)
  118. const commitHash = await git.commit(buildCommitMessage(chapterNum, frontMatter.标题, commitLines))
  119. for (const b of chapterBackups) {
  120. try {
  121. await fs.rm(b.backup, { force: true })
  122. } catch {
  123. // 备份残留不影响已完成 commit;文件名不以 .md 结尾,不进缓存扫描
  124. }
  125. }
  126. // P0-1:定稿后同步刷新缓存,避免 next 读旧章号重抄本章。
  127. // 重建失败不阻断定稿(已 commit 入档),但不能继续保留旧缓存,否则 next 会读旧章号。
  128. cacheRefresh = await refreshCacheAfterSourceChange(ctx)
  129. // 4. 清工作区(必须在 commit 成功之后;失败只记 warning——
  130. // commit 已经发生,任何清理问题都不得把结果反转成"失败/已回滚"(R4))
  131. const warnings = await cleanWorkspaceFiles(repoPath, workspaceFiles)
  132. return { ok: true, commitHash, cacheRefresh, warnings, error: '' }
  133. } catch (err) {
  134. // commit 前中断:回滚本次 stage/rollback 集合(非整棵 定稿/大纲 子树,避免误伤同子树其他章手改)。
  135. // 逐文件 restore:新章文件未跟踪会让整条 restore 报错被吞,逐个跑才能精确复原已跟踪文件。
  136. const relStageFiles = [...new Set(stageFiles)].map((f) => path.relative(repoPath, f))
  137. const relRollbackFiles = [...new Set(rollbackFiles)].map((f) => path.relative(repoPath, f))
  138. try {
  139. for (const rel of relStageFiles) {
  140. await git.restore([rel])
  141. }
  142. await git.clean(relRollbackFiles)
  143. for (const b of chapterBackups.toReversed()) {
  144. await fs.rm(b.original, { force: true })
  145. await fs.rename(b.backup, b.original)
  146. }
  147. for (const b of chapterBackups) {
  148. const rel = path.relative(repoPath, b.original)
  149. if (!(await git.hasDiff([rel]))) await git.restore([rel])
  150. }
  151. } catch {
  152. // 回滚尽力而为;M3 git 健康检查兜底
  153. }
  154. return {
  155. ok: false,
  156. error: `定稿中断,已回滚未提交写入、工作区原样保留:${err.message}`,
  157. }
  158. }
  159. }
  160. function buildCommitMessage(chapterNum, title, lines) {
  161. let msg = `ch(${chapterNum}): ${title}`
  162. const extras = []
  163. if (lines.条目) extras.push(`条目: ${lines.条目}`)
  164. if (lines.设定) extras.push(`设定: ${lines.设定}`)
  165. if (extras.length) msg += '\n\n' + extras.join('\n')
  166. return msg
  167. }
  168. /**
  169. * commit 后的工作区清理:归一路径(剥前缀+拒 `..`,C2/G-4)、目录也能清(recursive),
  170. * 任何失败只收 warning 永不抛——本函数在原子点之后运行,不许影响定稿结果。
  171. */
  172. async function cleanWorkspaceFiles(repoPath, workspaceFiles) {
  173. const warnings = []
  174. for (const wf of workspaceFiles) {
  175. const name = normalizeWorkspaceRel(wf)
  176. if (!name) {
  177. warnings.push(`工作区清理跳过可疑路径「${wf}」(不在工作区内)。`)
  178. continue
  179. }
  180. try {
  181. await fs.rm(path.join(repoPath, '工作区', name), { recursive: true, force: true })
  182. } catch (err) {
  183. warnings.push(`工作区/${name} 清理失败(${err.message})——本章已入档,稍后手动删除即可。`)
  184. }
  185. }
  186. return warnings
  187. }