index.js 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  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. /**
  11. * 定稿:原子 commit(D3)。写工作树 → git add → commit → 最后清工作区。
  12. * 断电安全:commit 前任何中断都回滚未提交写入(仅 定稿/大纲,不碰工作区),
  13. * 工作区草稿原样保留,不存在半章入档。
  14. *
  15. * @param {{repoPath: string, cache?: object}} ctx
  16. * @param {object} payload 定稿包(章档案/正文/摘要/条目/设定变更/commit 行/待清工作区文件)
  17. * @param {{git?: object, faultAfterWrite?: boolean}} [opts] 注入 git(测试)/故障注入(断电模拟)
  18. * @returns {Promise<{ok: boolean, commitHash?: string, error?: string}>}
  19. */
  20. export async function finalizeChapter(ctx, payload, opts = {}) {
  21. const { repoPath } = ctx
  22. const git = opts.git || createGit(repoPath)
  23. const {
  24. chapterNum,
  25. frontMatter,
  26. body = '',
  27. summary = null,
  28. threadUpdates = [],
  29. characterUpdates = [],
  30. rosterUpserts = [],
  31. timelineRows = [],
  32. secretWrites = [],
  33. commitLines = {},
  34. workspaceFiles = [],
  35. } = payload
  36. // 1. 校验(不过则什么都没写,天然原样)
  37. if (!Number.isInteger(chapterNum)) return { ok: false, error: '章号必须是整数' }
  38. if (!frontMatter || !frontMatter.标题) return { ok: false, error: '缺少章档案或标题' }
  39. const written = []
  40. try {
  41. // 2. 写工作树(全部落 定稿/大纲,非 工作区)
  42. const cw = await new ChapterWriter(repoPath).writeChapter(chapterNum, frontMatter, body)
  43. if (!cw.ok) throw new Error(cw.error)
  44. written.push(cw.filePath)
  45. if (summary != null) {
  46. const sw = await new SummaryWriter(repoPath).writeChapterSummary(chapterNum, summary)
  47. if (!sw.ok) throw new Error(sw.error)
  48. written.push(sw.filePath)
  49. }
  50. const tlw = new ThreadLedgerWriter(repoPath)
  51. for (const t of threadUpdates) {
  52. if (t.updates) {
  53. const r = await tlw.updateThread(t.id, t.updates)
  54. if (!r.ok) throw new Error(r.error)
  55. }
  56. if (t.history) {
  57. const r = await tlw.appendHistory(t.id, t.history)
  58. if (!r.ok) throw new Error(r.error)
  59. }
  60. const f = await tlw._findThreadFile(t.id)
  61. if (f) written.push(f)
  62. }
  63. const ew = new EntityWriter(repoPath)
  64. for (const c of characterUpdates) {
  65. const r = await ew.updateCharacter(c.name, c.updates)
  66. if (!r.ok) throw new Error(r.error)
  67. written.push(path.join(repoPath, '定稿', '设定', '角色', `${c.name}.md`))
  68. }
  69. for (const row of rosterUpserts) {
  70. const r = await ew.upsertRosterRow(row)
  71. if (!r.ok) throw new Error(r.error)
  72. written.push(path.join(repoPath, '定稿', '设定', '名册.md'))
  73. }
  74. const tw = new TimelineWriter(repoPath)
  75. for (const tr of timelineRows) {
  76. const r = await tw.appendRow(tr.volumeNum, tr.row)
  77. if (!r.ok) throw new Error(r.error)
  78. written.push(r.filePath)
  79. }
  80. const secw = new SecretWriter(repoPath)
  81. for (const s of secretWrites) {
  82. const r = await secw.write(s.id, s.frontMatter, s.content)
  83. if (!r.ok) throw new Error(r.error)
  84. written.push(r.filePath)
  85. }
  86. // 故障注入点(断电模拟,仅测试用)
  87. if (opts.faultAfterWrite) throw new Error('注入故障:写工作树后、commit 前中断')
  88. // 3. git add + commit(原子点)
  89. const relFiles = [...new Set(written)].map((f) => path.relative(repoPath, f))
  90. await git.add(relFiles)
  91. const commitHash = await git.commit(buildCommitMessage(chapterNum, frontMatter.标题, commitLines))
  92. // 4. 清工作区(必须在 commit 成功之后)
  93. for (const wf of workspaceFiles) {
  94. await fs.rm(path.join(repoPath, '工作区', wf), { force: true })
  95. }
  96. return { ok: true, commitHash, error: '' }
  97. } catch (err) {
  98. // commit 前中断:回滚未提交写入(仅 定稿/大纲),工作区原样保留
  99. try {
  100. await git.restore(['定稿/', '大纲/'])
  101. await git.clean(['定稿/', '大纲/'])
  102. } catch {
  103. // 回滚尽力而为;M3 git 健康检查兜底
  104. }
  105. return {
  106. ok: false,
  107. error: `定稿中断,已回滚未提交写入、工作区原样保留:${err.message}`,
  108. }
  109. }
  110. }
  111. function buildCommitMessage(chapterNum, title, lines) {
  112. let msg = `ch(${chapterNum}): ${title}`
  113. const extras = []
  114. if (lines.条目) extras.push(`条目: ${lines.条目}`)
  115. if (lines.设定) extras.push(`设定: ${lines.设定}`)
  116. if (extras.length) msg += '\n\n' + extras.join('\n')
  117. return msg
  118. }