native-write-checks.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import assert from 'node:assert/strict'
  2. import * as fs from 'node:fs'
  3. import * as path from 'node:path'
  4. import { pathToFileURL } from 'node:url'
  5. /** Exercises the actual Loader, tool pipeline, observation policy, provider and Git. */
  6. export async function checkNativeWrites({ root, book, main, ctx, service, execute, git, check, report }) {
  7. const core = await import(pathToFileURL(path.join(root, 'native-write-core.mjs')).href)
  8. const bookId = 'loader-book'
  9. const writes = []
  10. const offResult = ctx.on('tools/result', (exec, result) => {
  11. if (exec.name === 'write' && exec.parent !== undefined) writes.push({ exec, result })
  12. })
  13. const read = async (relative, agent = main) => {
  14. const result = await execute(agent, 'read', { file_path: path.join(book, relative) })
  15. assert.equal(result.isError, false, JSON.stringify(result))
  16. }
  17. const call = async (name, args, agent = main) => {
  18. const result = await execute(agent, name, { bookId, ...args })
  19. assert.equal(result.isError, false, JSON.stringify(result))
  20. return result.value
  21. }
  22. const text = relative => fs.readFileSync(path.join(book, relative), 'utf8')
  23. const put = (relative, content) => {
  24. fs.mkdirSync(path.dirname(path.join(book, relative)), { recursive: true })
  25. fs.writeFileSync(path.join(book, relative), content)
  26. }
  27. const head = () => git('rev-parse', 'HEAD').trim()
  28. const contract = { partName: '题材与读者定位', state: '已确认', content: '原生写入确认的新定位' }
  29. const contractPath = '作品契约/契约.md'
  30. const skeletonPath = '大纲/故事骨架.md'
  31. const outline = '# 卷纲\n\n## 叙事结构\n开篇\n\n## 弧线\n成长\n\n## 线索推进\n伏笔\n\n## 卷末兑现\n兑现\n'
  32. try {
  33. await check('七类设计文件复用原生观察与 design 提交', async () => {
  34. const cases = [
  35. ['novel_update_contract', contractPath, contract, core.contractTemplate({
  36. '叙事方式与文风基调': { state: '已确认', body: '作者保留的文风约定' },
  37. }, { 书id: bookId, 扩展字段: '原样保留' })],
  38. ['novel_update_skeleton', skeletonPath, { 正文: '# 新故事骨架\n' }, core.serializeDocument({ 版本: 2, 父版本: 1, 状态: '已确认', 扩展字段: '原样保留' }, '# 旧骨架')],
  39. ['novel_update_volume_layout', '大纲/分卷布局.md', { 正文: '# 新分卷布局\n' }, core.serializeDocument({ 版本: 2, 父版本: 1, 状态: '已确认', 扩展字段: '原样保留' }, '# 旧布局')],
  40. ['novel_confirm_volume_outline', '大纲/卷规划/卷01/卷纲.md', { 卷: 1, 目标: '卷纲', 正文: outline }, '# 旧卷纲\n'],
  41. ['novel_confirm_volume_outline', '大纲/卷规划/卷01/计划时间线.md', { 卷: 1, 目标: '计划时间线', 正文: '# 新计划时间线\n' }, '# 旧计划时间线\n'],
  42. ['novel_roll_window', '大纲/卷规划/卷01/近期窗口.md', { 卷: 1, action: '追加', 名称: '新窗口任务' }, '# 近期窗口\n\n- 旧任务 〔可进入〕\n'],
  43. ['novel_confirm_worldbook_entry', '世界书/人物档案/原生主角.md', { 模块: '人物档案', 名称: '原生主角', 类型: '人物', 性质: '计划', 状态: '已确认', 来源: '作者对谈', 正文: '# 新人物正文\n' }, '# 旧人物正文\n'],
  44. ]
  45. for (const [, relative, , content] of cases) put(relative, content)
  46. git('add', '--', ...cases.map(([, relative]) => relative))
  47. git('commit', '--quiet', '-m', 'design: native guard fixtures')
  48. for (const [name, relative, args] of cases) {
  49. const before = text(relative)
  50. const beforeHead = head()
  51. const count = writes.length
  52. const unread = await call(name, args)
  53. assert.equal(unread.ok, false, `${name}: ${JSON.stringify(unread)}`)
  54. assert.match(unread.reason, /has not been read/)
  55. assert.equal(text(relative), before)
  56. assert.equal(head(), beforeHead)
  57. await read(relative)
  58. const written = await call(name, args)
  59. assert.equal(written.ok, true, `${name}: ${JSON.stringify(written)}`)
  60. assert.equal(writes.length, count + 2, name)
  61. assert.equal(writes.at(-1).result.isError, false, name)
  62. assert.equal(writes.at(-1).exec.agent, main)
  63. assert.notEqual(head(), beforeHead)
  64. assert.match(git('log', '-1', '--format=%s'), /^design:/)
  65. assert.ok(git('-c', 'core.quotepath=false', 'show', '--name-only', '--format=', 'HEAD').includes(relative))
  66. }
  67. assert.match(text(contractPath), /作者保留的文风约定/)
  68. assert.match(text(contractPath), /书id: loader-book/)
  69. assert.match(text(contractPath), /扩展字段: 原样保留/)
  70. for (const relative of [skeletonPath, '大纲/分卷布局.md']) {
  71. assert.equal(core.parseDocument(text(relative)).data.fields.版本, 3)
  72. assert.equal(core.parseDocument(text(relative)).data.fields.父版本, 2)
  73. assert.match(text(relative), /扩展字段: 原样保留/)
  74. }
  75. report.nativeWriteGuards = { targets: cases.length, unreadRejected: cases.length, observedCommitted: cases.length }
  76. const audits = fs.readdirSync(path.join(book, '.webnovel/operations'))
  77. .map(file => JSON.parse(fs.readFileSync(path.join(book, '.webnovel/operations', file), 'utf8')))
  78. for (const [, relative] of cases) {
  79. assert.ok(audits.some(audit => audit.status === 'failed' && audit.targets.includes(relative)), relative)
  80. }
  81. })
  82. await check('作者保存和落盘前并发修改使旧观察失效', async () => {
  83. await read(contractPath)
  84. const before = text(contractPath)
  85. const saved = core.saveAuthorDocument(book, {
  86. path: contractPath, expectedHash: core.documentHash(before),
  87. body: core.parseDocument(before).data.body.replace('作者保留的文风约定', '作者刚保存的新文风约定'),
  88. operationId: 'native-guard-author-save', provenance: { sessionId: main.id, agentId: main.id, toolName: 'author-editor' },
  89. })
  90. assert.equal(saved.path, contractPath)
  91. assert.match(git('log', '-1', '--format=%s'), /^fix:/)
  92. const authorText = text(contractPath)
  93. const authorHead = head()
  94. const args = { ...contract, content: '作者保存后的新定位' }
  95. const stale = await call('novel_update_contract', args)
  96. assert.equal(stale.ok, false, JSON.stringify(stale))
  97. assert.match(stale.reason, /re-read the file/)
  98. assert.equal(text(contractPath), authorText)
  99. assert.equal(head(), authorHead)
  100. await read(contractPath)
  101. assert.equal((await call('novel_update_contract', args)).ok, true)
  102. assert.match(text(contractPath), /作者刚保存的新文风约定/)
  103. const beforeRace = head()
  104. let raced = false
  105. const offRace = ctx.on('tools/pre-execute', async (exec, next) => {
  106. if (!raced && exec.agent === main && exec.name === 'write' && exec.parent !== undefined) {
  107. raced = true
  108. fs.appendFileSync(path.join(book, contractPath), '\n作者在准备完成后追加的文字\n')
  109. }
  110. return next()
  111. })
  112. try {
  113. const conflict = await call('novel_update_contract', { ...args, content: '竞争写入不得落盘' })
  114. assert.equal(raced, true)
  115. assert.equal(conflict.ok, false, JSON.stringify(conflict))
  116. assert.match(conflict.reason, /re-read the file/)
  117. assert.match(text(contractPath), /作者在准备完成后追加的文字/)
  118. assert.doesNotMatch(text(contractPath), /竞争写入不得落盘/)
  119. assert.equal(head(), beforeRace)
  120. } finally { offRace() }
  121. })
  122. await check('读观察按会话隔离且普通嵌套调用不能绕过门禁', async () => {
  123. await read(contractPath)
  124. const independent = await service('agents').create({ sessionId: 'native-independent', meta: { cwd: path.dirname(book) } })
  125. const before = text(contractPath)
  126. const beforeHead = head()
  127. try {
  128. const unread = await call('novel_update_contract', contract, independent.agent)
  129. assert.equal(unread.ok, false, JSON.stringify(unread))
  130. assert.match(unread.reason, /has not been read/)
  131. } finally { await independent.dispose() }
  132. for (const [name, args] of [
  133. ['write', { file_path: path.join(book, contractPath), content: '不应直接覆盖' }],
  134. ['edit', { file_path: path.join(book, contractPath), old_string: '作者', new_string: '模型' }],
  135. ]) assert.equal((await execute(main, name, args)).isError, true)
  136. const offProbe = main.ctx.get('tools').register({
  137. name: 'untrusted_native_probe', description: 'Test-only nested mutation without a bridge grant',
  138. parameters: { type: 'object', properties: {} },
  139. output: {
  140. schema: { type: 'object', properties: { denied: { type: 'boolean' } }, required: ['denied'] },
  141. render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
  142. },
  143. execute: async (_args, exec) => {
  144. const result = await service('tools').execute({
  145. agent: main, name: 'write', arguments: { file_path: path.join(book, contractPath), content: '普通嵌套不得写入' },
  146. callId: 'untrusted-nested-write', rootCallId: exec.rootCallId, parent: exec.token, signal: exec.signal,
  147. })
  148. assert.equal(result.isError, true, JSON.stringify(result))
  149. return { denied: result.isError }
  150. },
  151. })
  152. try {
  153. const probe = await execute(main, 'untrusted_native_probe', {})
  154. assert.equal(probe.isError, false, JSON.stringify(probe))
  155. } finally { offProbe() }
  156. assert.equal(text(contractPath), before)
  157. assert.equal(head(), beforeHead)
  158. })
  159. await check('原生写入后提交失败可原样重跑且不重复升版', async () => {
  160. const args = { 正文: '# 提交重试后的骨架\n' }
  161. await read(skeletonPath)
  162. const beforeHead = head()
  163. const version = core.parseDocument(text(skeletonPath)).data.fields.版本
  164. const gitLock = path.join(book, '.git/index.lock')
  165. fs.writeFileSync(gitLock, 'fixture lock', { flag: 'wx' })
  166. try {
  167. const failed = await call('novel_update_skeleton', args)
  168. assert.equal(failed.ok, false, JSON.stringify(failed))
  169. assert.match(failed.reason, /已落盘但未提交/)
  170. assert.equal(head(), beforeHead)
  171. } finally { fs.unlinkSync(gitLock) }
  172. const saved = text(skeletonPath)
  173. assert.equal(core.parseDocument(saved).data.fields.版本, version + 1)
  174. assert.equal((await call('novel_update_skeleton', args)).ok, true)
  175. assert.equal(text(skeletonPath), saved)
  176. const committed = head()
  177. assert.notEqual(committed, beforeHead)
  178. const replay = await call('novel_update_skeleton', args)
  179. assert.equal(replay.ok, true, JSON.stringify(replay))
  180. assert.match(replay.message, /幂等重放/)
  181. assert.equal(text(skeletonPath), saved)
  182. assert.equal(head(), committed)
  183. })
  184. await check('多文件改稿保留事务且不拆成原生单文件写入', async () => {
  185. const count = writes.length
  186. const beforeHead = head()
  187. const input = { 卷: 1, 章: 2, 章名: '事务测试', 角色: '待审稿', 生成模块: '作者手写', 正文: '第一份作者正文。' }
  188. const first = await call('novel_import_draft', input)
  189. assert.equal(first.ok, true, JSON.stringify(first))
  190. const second = await call('novel_import_draft', { ...input, 生成模块: '作者手改', 正文: '第二份作者正文。' })
  191. assert.equal(second.ok, true, JSON.stringify(second))
  192. assert.notEqual(first.relPath, second.relPath)
  193. assert.equal(core.parseDocument(text(first.relPath)).data.fields.角色, '草稿')
  194. assert.equal(core.parseDocument(text(second.relPath)).data.fields.角色, '待审稿')
  195. assert.equal(core.parseDocument(text(second.relPath)).data.fields.父版本, 1)
  196. assert.equal(writes.length, count)
  197. assert.equal(head(), beforeHead)
  198. assert.deepEqual(fs.readdirSync(path.join(book, '.webnovel/transactions')), [])
  199. })
  200. } finally { offResult() }
  201. }