@bevel-software/platform-core-backend 0.25.2 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/agent-guide/access-control.md +234 -0
  2. package/agent-guide/conventions.md +27 -0
  3. package/agent-guide/directory-structure.md +145 -0
  4. package/agent-guide/finding-things.md +7 -0
  5. package/agent-guide/introduction.md +27 -0
  6. package/agent-guide/skills.md +47 -0
  7. package/agent-guide/tool-manuals.md +217 -0
  8. package/agent-guide/where-a-new-file-goes.md +36 -0
  9. package/dist/assets.d.ts +7 -0
  10. package/dist/assets.d.ts.map +1 -1
  11. package/dist/assets.js +9 -0
  12. package/dist/assets.js.map +1 -1
  13. package/dist/core/core-ports.d.ts +11 -0
  14. package/dist/core/core-ports.d.ts.map +1 -1
  15. package/dist/core/core-ports.js.map +1 -1
  16. package/dist/core/create-core-server.d.ts.map +1 -1
  17. package/dist/core/create-core-server.js +13 -2
  18. package/dist/core/create-core-server.js.map +1 -1
  19. package/dist/core/create-core-services.d.ts +9 -0
  20. package/dist/core/create-core-services.d.ts.map +1 -1
  21. package/dist/core/create-core-services.js +14 -4
  22. package/dist/core/create-core-services.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/modules/access/access-control.interface.d.ts +9 -0
  28. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  29. package/dist/modules/access/access-control.service.d.ts +1 -0
  30. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  31. package/dist/modules/access/access-control.service.js +16 -0
  32. package/dist/modules/access/access-control.service.js.map +1 -1
  33. package/dist/modules/agent-guide/agent-guide.d.ts +139 -0
  34. package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -0
  35. package/dist/modules/agent-guide/agent-guide.js +191 -0
  36. package/dist/modules/agent-guide/agent-guide.js.map +1 -0
  37. package/dist/modules/agent-guide/agent-guide.tools.d.ts +24 -0
  38. package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -0
  39. package/dist/modules/agent-guide/agent-guide.tools.js +100 -0
  40. package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -0
  41. package/dist/modules/agent-guide/index.d.ts +4 -0
  42. package/dist/modules/agent-guide/index.d.ts.map +1 -0
  43. package/dist/modules/agent-guide/index.js +4 -0
  44. package/dist/modules/agent-guide/index.js.map +1 -0
  45. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +3 -2
  46. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  47. package/dist/modules/agent-instructions/agent-instructions.routes.js +3 -2
  48. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  49. package/dist/modules/agent-instructions/compose.d.ts +9 -6
  50. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  51. package/dist/modules/agent-instructions/compose.js +9 -6
  52. package/dist/modules/agent-instructions/compose.js.map +1 -1
  53. package/dist/modules/agent-instructions/index.d.ts +1 -1
  54. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  55. package/dist/modules/agent-instructions/index.js +1 -1
  56. package/dist/modules/agent-instructions/index.js.map +1 -1
  57. package/dist/modules/agent-instructions/shared-file-rules.d.ts +10 -50
  58. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
  59. package/dist/modules/agent-instructions/shared-file-rules.js +32 -85
  60. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  61. package/dist/modules/mcp/mcp.service.d.ts +29 -2
  62. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  63. package/dist/modules/mcp/mcp.service.js +113 -16
  64. package/dist/modules/mcp/mcp.service.js.map +1 -1
  65. package/dist/modules/mcp/tool-schema-guard.d.ts +105 -0
  66. package/dist/modules/mcp/tool-schema-guard.d.ts.map +1 -0
  67. package/dist/modules/mcp/tool-schema-guard.js +171 -0
  68. package/dist/modules/mcp/tool-schema-guard.js.map +1 -0
  69. package/dist/modules/plugins/plugins.tools.d.ts +36 -2
  70. package/dist/modules/plugins/plugins.tools.d.ts.map +1 -1
  71. package/dist/modules/plugins/plugins.tools.js +71 -14
  72. package/dist/modules/plugins/plugins.tools.js.map +1 -1
  73. package/dist/modules/settings/deployment-settings.service.d.ts +0 -7
  74. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  75. package/dist/modules/settings/deployment-settings.service.js +14 -53
  76. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  77. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  78. package/dist/modules/settings/setup.routes.js +3 -6
  79. package/dist/modules/settings/setup.routes.js.map +1 -1
  80. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  81. package/dist/modules/skills/skills.tools.js +58 -16
  82. package/dist/modules/skills/skills.tools.js.map +1 -1
  83. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +23 -4
  84. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  85. package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -1
  86. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +4 -0
  87. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  88. package/dist/modules/tool-manuals/tool-manuals.service.js +14 -0
  89. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  90. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +7 -0
  91. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.tools.js +66 -36
  93. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  94. package/dist/modules/tool-registry/description-length.d.ts +14 -14
  95. package/dist/modules/tool-registry/description-length.d.ts.map +1 -1
  96. package/dist/modules/tool-registry/description-length.js +24 -26
  97. package/dist/modules/tool-registry/description-length.js.map +1 -1
  98. package/dist/modules/tool-registry/guide-first.d.ts +23 -0
  99. package/dist/modules/tool-registry/guide-first.d.ts.map +1 -0
  100. package/dist/modules/tool-registry/guide-first.js +32 -0
  101. package/dist/modules/tool-registry/guide-first.js.map +1 -0
  102. package/dist/modules/tool-registry/tool-registry.d.ts +6 -0
  103. package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -1
  104. package/dist/modules/tool-registry/tool-registry.js +9 -2
  105. package/dist/modules/tool-registry/tool-registry.js.map +1 -1
  106. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts +449 -0
  107. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts.map +1 -0
  108. package/dist/modules/workflow/agent-tools/change-request-read-shape.js +481 -0
  109. package/dist/modules/workflow/agent-tools/change-request-read-shape.js.map +1 -0
  110. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts +73 -0
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -0
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +582 -0
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -0
  114. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +12 -1
  115. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/change-request-summary.js +5 -1
  117. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  118. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  119. package/dist/modules/workflow/agent-tools/workflow.tools.js +9 -0
  120. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  121. package/dist/modules/workflow/git/git.service.d.ts +210 -13
  122. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  123. package/dist/modules/workflow/git/git.service.js +456 -91
  124. package/dist/modules/workflow/git/git.service.js.map +1 -1
  125. package/dist/modules/workflow/git/merge-commit.d.ts +73 -0
  126. package/dist/modules/workflow/git/merge-commit.d.ts.map +1 -0
  127. package/dist/modules/workflow/git/merge-commit.js +89 -0
  128. package/dist/modules/workflow/git/merge-commit.js.map +1 -0
  129. package/dist/modules/workflow/git/pull-request.service.d.ts +94 -1
  130. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  131. package/dist/modules/workflow/git/pull-request.service.js +332 -37
  132. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  133. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +35 -0
  134. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  135. package/dist/modules/workflow/review-workflow/review-workflow.service.js +178 -12
  136. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  137. package/dist/modules/workflow/workflow.routes.d.ts +6 -2
  138. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  139. package/dist/modules/workflow/workflow.routes.js +7 -2
  140. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  141. package/dist/modules/workflow/workflow.service.d.ts +4 -0
  142. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  143. package/dist/modules/workflow/workflow.service.js +3 -0
  144. package/dist/modules/workflow/workflow.service.js.map +1 -1
  145. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  146. package/dist/modules/workspace/startup/steps/seed-tree.js +22 -27
  147. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  148. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +58 -52
  149. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  150. package/dist/modules/workspace/startup/steps/template-files.step.js +209 -223
  151. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  152. package/dist/modules/workspace/startup/steps/template-source.d.ts +5 -3
  153. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  154. package/dist/modules/workspace/startup/steps/template-source.js +5 -3
  155. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  156. package/dist/modules/workspace/workspace.tools.d.ts +10 -1
  157. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  158. package/dist/modules/workspace/workspace.tools.js +211 -18
  159. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  160. package/dist/shared/domain-errors.d.ts +11 -0
  161. package/dist/shared/domain-errors.d.ts.map +1 -1
  162. package/dist/shared/domain-errors.js +14 -0
  163. package/dist/shared/domain-errors.js.map +1 -1
  164. package/dist/shared/hidden-tools.d.ts +44 -0
  165. package/dist/shared/hidden-tools.d.ts.map +1 -0
  166. package/dist/shared/hidden-tools.js +13 -0
  167. package/dist/shared/hidden-tools.js.map +1 -0
  168. package/kb-template/.bevelignore +0 -5
  169. package/package.json +4 -3
  170. package/src/__tests__/kb-layout-config.test.ts +10 -100
  171. package/src/__tests__/packaged-assets-ship.test.ts +54 -0
  172. package/src/assets.ts +10 -0
  173. package/src/core/core-ports.ts +11 -0
  174. package/src/core/create-core-server.ts +13 -2
  175. package/src/core/create-core-services.ts +28 -4
  176. package/src/index.ts +2 -2
  177. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +58 -0
  178. package/src/modules/access/__tests__/access-control.platform-restore.test.ts +8 -7
  179. package/src/modules/access/__tests__/access-personal-plugin.test.ts +1 -18
  180. package/src/modules/access/access-control.interface.ts +15 -0
  181. package/src/modules/access/access-control.service.ts +21 -0
  182. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +328 -0
  183. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +189 -0
  184. package/src/modules/agent-guide/agent-guide.tools.ts +122 -0
  185. package/src/modules/agent-guide/agent-guide.ts +291 -0
  186. package/src/modules/agent-guide/index.ts +21 -0
  187. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +28 -121
  188. package/src/modules/agent-instructions/agent-instructions.routes.ts +3 -2
  189. package/src/modules/agent-instructions/compose.ts +9 -6
  190. package/src/modules/agent-instructions/index.ts +0 -3
  191. package/src/modules/agent-instructions/shared-file-rules.ts +31 -93
  192. package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +14 -3
  193. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +250 -0
  194. package/src/modules/mcp/__tests__/mcp.service.test.ts +31 -23
  195. package/src/modules/mcp/__tests__/tool-schema-guard.test.ts +266 -0
  196. package/src/modules/mcp/mcp.service.ts +137 -19
  197. package/src/modules/mcp/tool-schema-guard.ts +196 -0
  198. package/src/modules/plugins/__tests__/plugins.tools.test.ts +154 -4
  199. package/src/modules/plugins/plugins.tools.ts +75 -15
  200. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +26 -55
  201. package/src/modules/settings/deployment-settings.service.ts +13 -54
  202. package/src/modules/settings/setup.routes.ts +3 -6
  203. package/src/modules/skills/__tests__/skills.tools.description.test.ts +91 -0
  204. package/src/modules/skills/skills.tools.ts +62 -16
  205. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +57 -0
  206. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +73 -4
  207. package/src/modules/tool-manuals/tool-manuals.contract.ts +24 -4
  208. package/src/modules/tool-manuals/tool-manuals.service.ts +17 -0
  209. package/src/modules/tool-manuals/tool-manuals.tools.ts +74 -36
  210. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +160 -0
  211. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +61 -59
  212. package/src/modules/tool-registry/description-length.ts +24 -26
  213. package/src/modules/tool-registry/guide-first.ts +34 -0
  214. package/src/modules/tool-registry/tool-registry.ts +9 -2
  215. package/src/modules/workflow/__tests__/apply-failure.test.ts +6 -1
  216. package/src/modules/workflow/agent-tools/__tests__/change-request-read-shape.test.ts +705 -0
  217. package/src/modules/workflow/agent-tools/__tests__/change-request-read.tools.test.ts +1518 -0
  218. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +23 -2
  219. package/src/modules/workflow/agent-tools/change-request-read-shape.ts +712 -0
  220. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +724 -0
  221. package/src/modules/workflow/agent-tools/change-request-summary.ts +5 -1
  222. package/src/modules/workflow/agent-tools/workflow.tools.ts +8 -0
  223. package/src/modules/workflow/git/__tests__/git.service.appliedChange.test.ts +285 -0
  224. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
  225. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +334 -0
  226. package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +72 -2
  227. package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +24 -2
  228. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +620 -1
  229. package/src/modules/workflow/git/git.service.ts +537 -94
  230. package/src/modules/workflow/git/merge-commit.ts +88 -0
  231. package/src/modules/workflow/git/pull-request.service.ts +380 -54
  232. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +7 -1
  233. package/src/modules/workflow/review-workflow/__tests__/merge-records-own-commit.test.ts +407 -0
  234. package/src/modules/workflow/review-workflow/review-workflow.service.ts +189 -11
  235. package/src/modules/workflow/workflow.routes.ts +7 -2
  236. package/src/modules/workflow/workflow.service.ts +7 -0
  237. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +4 -3
  238. package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +21 -10
  239. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +33 -55
  240. package/src/modules/workspace/__tests__/workspace.tools.test.ts +255 -22
  241. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +191 -489
  242. package/src/modules/workspace/startup/steps/seed-tree.ts +21 -27
  243. package/src/modules/workspace/startup/steps/template-files.step.ts +217 -249
  244. package/src/modules/workspace/startup/steps/template-source.ts +5 -3
  245. package/src/modules/workspace/workspace.tools.ts +226 -16
  246. package/src/shared/domain-errors.ts +15 -0
  247. package/src/shared/hidden-tools.ts +45 -0
  248. package/kb-template/AGENTS.md +0 -730
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The subject line a change request's merge commit carries, written in one place
3
+ * and READ BACK in another.
4
+ *
5
+ * `mergePr` has always built this subject; what is new is that something now
6
+ * depends on reading it. An applied change request is read back from the merge
7
+ * commit its row records (`merged_sha`), and that row cannot be trusted to point
8
+ * at a commit this request created: when the target already contains the source,
9
+ * `mergeChangeRequest` makes no commit at all and reports the TARGET TIP as the
10
+ * merged state. In a deployment that lands everything through change requests
11
+ * that tip is usually ANOTHER request's merge commit — so reading "the recorded
12
+ * commit's own change" would answer with somebody else's files, under this
13
+ * request's number (cubic P1 on PR #347).
14
+ *
15
+ * The write side is fixed too — a merge that creates no commit now records no
16
+ * sha — but rows written before that fix exist, so the read verifies rather than
17
+ * assumes. The number in the subject is the cheapest honest proof available: it
18
+ * is in the commit, it is immutable, and no other request's merge commit carries
19
+ * it.
20
+ *
21
+ * Both functions live here, next to each other, so the format cannot drift on
22
+ * one side only. `git.service.appliedChangeShas` is tested against a commit
23
+ * written with {@link mergeCommitSubject}, so changing the format without
24
+ * changing the predicate fails the suite rather than quietly making every
25
+ * applied request author-only.
26
+ */
27
+
28
+ /**
29
+ * The merge commit's subject for request `number` titled `title`.
30
+ *
31
+ * The title is flattened to ONE line, because the number only proves anything
32
+ * where git reports it. `%s` is the first PARAGRAPH of the message joined with
33
+ * spaces, so a single newline in the title is harmless — but a BLANK line (two
34
+ * newlines, or a whitespace-only line) ends that paragraph, and nothing rejects
35
+ * one: a title is stored `.trim()`-ed and the tool schema bounds only its
36
+ * length. The subject would then end before `(#N)`,
37
+ * {@link mergeCommitSubjectNames} would refuse the request's OWN merge commit,
38
+ * and every applied request with such a title would read as author-only with no
39
+ * files (cubic P2 on PR #347).
40
+ *
41
+ * Flattened here rather than rejected at the write side so the titles already
42
+ * stored are covered too — and because a merge is the wrong moment to discover
43
+ * that a title typed days ago is unacceptable.
44
+ */
45
+ export function mergeCommitSubject(title: string, number: number): string {
46
+ return `${title.replace(/\s+/g, ' ').trim()} (#${number})`;
47
+ }
48
+
49
+ /**
50
+ * Whether `subject` is the subject of request `number`'s own merge commit.
51
+ *
52
+ * Matched at the END, because that is where {@link mergeCommitSubject} puts the
53
+ * number and a title may contain anything — including another request's number,
54
+ * which a looser match would accept.
55
+ */
56
+ export function mergeCommitSubjectNames(subject: string, number: number): boolean {
57
+ return subject.trimEnd().endsWith(`(#${number})`);
58
+ }
59
+
60
+ /**
61
+ * Whether `message` — a merge commit's WHOLE message — is request `number`'s
62
+ * own, by the subject rule above or, given the request's stored `title`, by the
63
+ * form an earlier release wrote.
64
+ *
65
+ * That release did not flatten the title, so a title with a blank line in it
66
+ * put `(#N)` after the blank line, and git's `%s` subject ended before it: such
67
+ * rows failed {@link mergeCommitSubjectNames} and read as author-only for good.
68
+ * The commit is still exactly `<title> (#N)` followed by a blank line and the
69
+ * body, and the row still holds the title — so a message that opens with the
70
+ * title (its whitespace matched loosely), then ` (#N)`, then the END OF THAT
71
+ * LINE, is this request's. The line end is what keeps the match this
72
+ * request's: a title may contain another request's number (`Fix crash (#42)`),
73
+ * and a CURRENT-format commit of request 43 with that title reads
74
+ * `Fix crash (#42) (#43)` — the number of 42 there is followed by more text on
75
+ * the line, so it does not name 42's commit. Only `title` can vouch for the
76
+ * old format: the number sits wherever that format put it, which nothing else
77
+ * in a message may say.
78
+ */
79
+ export function mergeCommitMessageNames(message: string, number: number, title?: string): boolean {
80
+ // The first paragraph, which is what git reports as `%s`.
81
+ const subject = message.split(/\r?\n[ \t]*\r?\n/, 1)[0] ?? '';
82
+ if (mergeCommitSubjectNames(subject.replace(/\s+/g, ' '), number)) return true;
83
+ if (title === undefined) return false;
84
+ const words = title.trim().split(/\s+/).map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
85
+ if (words.length === 0 || words[0] === '') return false;
86
+ const oldFormat = new RegExp(`^\\s*${words.join('\\s+')}\\s+\\(#${number}\\)[ \\t]*(?:\\r?\\n|$)`);
87
+ return oldFormat.test(message);
88
+ }
@@ -1,8 +1,10 @@
1
- import { desc, eq } from 'drizzle-orm';
1
+ import { desc, eq, inArray } from 'drizzle-orm';
2
2
  import { logger } from '../../../shared/logging.js';
3
3
 
4
4
  const log = logger('cr');
5
5
  import type {
6
+ AppliedChangeRef,
7
+ ChangedPathPair,
6
8
  FileApprovalState,
7
9
  IPullRequestService,
8
10
  PrReviewComment,
@@ -18,15 +20,127 @@ import { changeRequests } from '../../database/schema.js';
18
20
  import type { WorkspaceService } from '../../workspace/workspace.service.js';
19
21
  import type { IAccessControl } from '../../access/access-control.interface.js';
20
22
  import { AccessUnreadableError } from '../../access-model/access-errors.js';
21
- import { WorkflowValidationError } from '../../../shared/domain-errors.js';
23
+ import { AppliedChangeMismatchError, WorkflowValidationError } from '../../../shared/domain-errors.js';
22
24
  import { canonicalEmail, hashEmail } from '../../../shared/email-identity.js';
23
25
  import { changeRequestLink, changeRequestLinkBase } from './change-request-link.js';
24
26
 
27
+ /**
28
+ * The latest moment a change-request row records — its close time when it has
29
+ * one, else its creation time, with `updated_at` folded in for the day
30
+ * something writes it. Deliberately derived from the ROW alone: a time taken
31
+ * from the newest comment or approval would make the same request report
32
+ * different "last changed" moments to a list (which reads no comments) and a
33
+ * detail (which does).
34
+ */
35
+ function latestRowMoment(row: {
36
+ createdAt: Date;
37
+ updatedAt: Date | null;
38
+ closedAt: Date | null;
39
+ }): string {
40
+ const times = [row.createdAt, row.updatedAt, row.closedAt]
41
+ .filter((d): d is Date => d instanceof Date)
42
+ .map((d) => d.getTime());
43
+ return new Date(Math.max(...times)).toISOString();
44
+ }
45
+
25
46
  const LIST_PR_CACHE_TTL_MS = 30_000;
47
+ /**
48
+ * How many applied requests' file lists are remembered at once. Each is a
49
+ * short path list, so this is about a deployment that applies requests for
50
+ * years never growing the map without bound. Past it, what is remembered
51
+ * stays and new answers are not kept — see `touchedPathsFor` for why nothing
52
+ * is evicted.
53
+ */
54
+ const MAX_REMEMBERED_APPLIED_CHANGES = 20_000;
55
+ /** How many mismatched applied rows to remember — see `touchedPathsFor`. */
56
+ const MAX_MISMATCHED_APPLIED_CHANGES = 5_000;
57
+ /** How many rows a listing reads from git at once. */
58
+ const SUMMARY_CONCURRENCY = 8;
59
+
60
+ /** `fn` over `items`, at most `limit` in flight, results in order. */
61
+ async function mapWithLimit<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
62
+ const results: R[] = new Array(items.length);
63
+ let next = 0;
64
+ const worker = async (): Promise<void> => {
65
+ while (next < items.length) {
66
+ const index = next;
67
+ next += 1;
68
+ results[index] = await fn(items[index]!);
69
+ }
70
+ };
71
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
72
+ return results;
73
+ }
26
74
  const DETAIL_CACHE_TTL_MS = 30_000;
27
75
 
28
76
  type ChangeRequestRow = typeof changeRequests.$inferSelect;
29
77
 
78
+ /**
79
+ * Where a change request's files are read from.
80
+ *
81
+ * - `branches` — the source/target pair, as a live proposal is read.
82
+ * - `commit` — the merge commit the row records, as an applied one is.
83
+ * - `none` — nothing: there is no durable record of what this request
84
+ * proposed, so it has no file list to show.
85
+ */
86
+ export type ChangeSource =
87
+ | { kind: 'branches' }
88
+ | { kind: 'commit'; applied: AppliedChangeRef }
89
+ | { kind: 'none' };
90
+
91
+ /**
92
+ * Which diff a change-request row is read from — asked ONCE, here, by both the
93
+ * list (`touchedPathsFor`) and the by-number detail (`getPrDetail`). The two
94
+ * surfaces answered this question separately before, and so answered it
95
+ * differently: the list gave a declined request no paths while the detail
96
+ * resolved its branch pair, which left `list_change_requests` and the four
97
+ * by-number tools contradicting each other about the same request for the same
98
+ * caller. One function, one answer.
99
+ *
100
+ * An OPEN request is its two branch tips, which is what a proposal IS.
101
+ *
102
+ * An APPLIED one is the merge commit its row records: the branch is retired, and
103
+ * the commit is local, immutable, and holds exactly what landed.
104
+ *
105
+ * A DECLINED one is read from NOTHING, and that is not a degradation:
106
+ *
107
+ * - The row records no sha, so nothing durable says what the request
108
+ * proposed. Declining retires the source branch as merging does
109
+ * (`deleteChangeRequest` ends in `retireMergedSourceBranch`) — unless
110
+ * another open request still needs that branch, which keeps it alive and
111
+ * moving. Where the branch pair still resolves it would answer with what
112
+ * that branch differs by NOW, work committed since for the request that
113
+ * kept it included; reading the declined request would then present later
114
+ * work as the proposal that was turned down.
115
+ * - An empty file set proves no read access downstream, so a declined request
116
+ * is readable by its author alone — the owner's criterion of 2026-10-02.
117
+ * - And it asks git nothing, so neither a listing nor a by-number read of a
118
+ * declined request costs a network round trip.
119
+ *
120
+ * A merged row that records no merge commit (or whose commit this clone has not
121
+ * fetched) lands on `none` too, and fails closed the same way until it can be
122
+ * read in full.
123
+ */
124
+ export function changeSourceFor(row: {
125
+ number: number;
126
+ state: string;
127
+ /** The stored title, handed along so a merge commit in the old message format is still recognised. */
128
+ title?: string;
129
+ mergedSha?: string | null;
130
+ }): ChangeSource {
131
+ if (row.state === 'open') return { kind: 'branches' };
132
+ // The NUMBER travels with the sha, because reading the commit's own change is
133
+ // only sound if the commit is this request's merge commit — and the number is
134
+ // what proves it (the subject ends with `(#<number>)`). A row whose
135
+ // `merged_sha` was written by a merge that made no commit points at the target
136
+ // tip, which is usually another request's merge commit; the git layer rejects
137
+ // that rather than answering with its files. See `merge-commit.ts`.
138
+ if (row.state === 'merged' && row.mergedSha) {
139
+ return { kind: 'commit', applied: { number: row.number, mergeSha: row.mergedSha, title: row.title } };
140
+ }
141
+ return { kind: 'none' };
142
+ }
143
+
30
144
  /**
31
145
  * The slice of the review-workflow service this module needs to compose detail
32
146
  * responses. Kept narrow (just what `getPrDetail` stitches in) so the PR
@@ -90,6 +204,14 @@ export class PullRequestService implements IPullRequestService {
90
204
  * list cache stores, so it lives and dies with them.
91
205
  */
92
206
  private routingPaths = new WeakMap<PullRequestSummary, string[]>();
207
+ /**
208
+ * The files an APPLIED request landed, per clone and merge commit. A merge
209
+ * commit is immutable, so its answer is good for the life of the process;
210
+ * nothing invalidates this, and nothing needs to. See {@link touchedPathsFor}.
211
+ */
212
+ private readonly appliedChanges = new Map<string, { paths: string[]; pairs: ChangedPathPair[] }>();
213
+ /** Applied rows whose recorded commit is not their own, by the same key — never asked again. */
214
+ private readonly mismatchedAppliedChanges = new Set<string>();
93
215
  /**
94
216
  * Per-CR detail cache, keyed by `${workspaceId ?? 'global'}:${viewer}:${number}`.
95
217
  * The payload includes per-file approvals resolved against the caller's
@@ -145,7 +267,11 @@ export class PullRequestService implements IPullRequestService {
145
267
  return this.workspaceService.findAnyWorkspaceId();
146
268
  }
147
269
 
148
- private rowToSummary(row: ChangeRequestRow, touchedNodePaths: string[]): PullRequestSummary {
270
+ private rowToSummary(
271
+ row: ChangeRequestRow,
272
+ touchedNodePaths: string[],
273
+ touchedNodeFiles: ChangedPathPair[],
274
+ ): PullRequestSummary {
149
275
  return {
150
276
  number: row.number,
151
277
  title: row.title,
@@ -160,7 +286,14 @@ export class PullRequestService implements IPullRequestService {
160
286
  base: row.targetBranch,
161
287
  state: row.state as PullRequestState,
162
288
  createdAt: row.createdAt.toISOString(),
289
+ // The latest moment the ROW records. Nothing stamps `updated_at` on a
290
+ // change request today, so in practice this is the close time of a
291
+ // closed request and the creation time of an open one — the honest
292
+ // answer from the row, with the column folded in for the day something
293
+ // does write it.
294
+ updatedAt: latestRowMoment(row),
163
295
  touchedNodePaths,
296
+ touchedNodeFiles,
164
297
  // Provider reviews are gone; the real approval state lives in the detail
165
298
  // view (per-file, DB-backed). The summary badge is derived there.
166
299
  review: { approvals: 0, changesRequested: 0, pendingLogins: [] },
@@ -183,25 +316,86 @@ export class PullRequestService implements IPullRequestService {
183
316
  /**
184
317
  * Cheap touched-paths for a CR row (empty when no workspace exists yet),
185
318
  * placeholders included — see {@link summaryOf} for what a summary shows.
319
+ *
320
+ * WHICH DIFF is {@link changeSourceFor}'s answer — the same one `getPrDetail`
321
+ * takes, so a list and a by-number read never disagree about which files a
322
+ * request has. No state reaches the network per request: an open row is
323
+ * diffed from two branch tips a single whole-clone refresh has already brought
324
+ * up to date (`fetch: false`), an applied one from a local immutable commit,
325
+ * and a declined one from nothing at all.
326
+ *
327
+ * The merge commit also undid the flood finding 1 of Razvan's review named:
328
+ * `publishedPrCommits` answers null for a retired branch, which sent
329
+ * `changedPathsAndPairsForPr` to `fetch` two refs — one of them gone — once PER
330
+ * REQUEST and then logged a warning for each, so a list of a few hundred
331
+ * applied requests opened a few hundred concurrent fetches to answer nothing.
186
332
  */
187
333
  private async touchedPathsFor(
188
334
  row: ChangeRequestRow,
189
335
  workspaceId: string | null,
190
336
  opts: { fetch?: boolean } = {},
191
- ): Promise<string[]> {
192
- if (!workspaceId) return [];
337
+ ): Promise<{ paths: string[]; pairs: ChangedPathPair[] }> {
338
+ const empty = { paths: [] as string[], pairs: [] as ChangedPathPair[] };
339
+ if (!workspaceId) return empty;
340
+ // Best-effort, but logged: an empty result silently hides a CR from the
341
+ // owner-routing match in `listPrsForOwnerEmail`, so a swallowed failure
342
+ // shouldn't be invisible.
343
+ const degrade = (what: string) => (err: unknown) => {
344
+ const where = `#${row.number} (${row.sourceBranch} → ${row.targetBranch}) in ${workspaceId}`;
345
+ // A clone that simply does not hold the merge commit yet is not a failure
346
+ // to shout about — it is this row's turn to be fetched, and the next list
347
+ // read answers it. Warning per request on that would be the log flood the
348
+ // network flood came with.
349
+ if (err instanceof WorkflowValidationError) {
350
+ log.debug(`${what} could not resolve ${where}; answering no touched paths:`, { err });
351
+ } else {
352
+ log.warn(`${what} failed for ${where}:`, { err });
353
+ }
354
+ return empty;
355
+ };
356
+ const source = changeSourceFor(row);
357
+ if (source.kind === 'none') return empty;
358
+ if (source.kind === 'commit') {
359
+ // Remembered once read: a merge commit never changes, so neither does
360
+ // its diff, and a list of a few hundred applied requests would otherwise
361
+ // run four git processes per row on every call. Only an ANSWER is kept.
362
+ // A commit this clone does not hold yet rejects, and that is this row's
363
+ // turn to be fetched, so the next read asks again.
364
+ const key = `${workspaceId}\u0000${source.applied.mergeSha}\u0000${source.applied.number}`;
365
+ const known = this.appliedChanges.get(key);
366
+ if (known) return known;
367
+ // A row whose commit the clone holds but which is NOT this request's
368
+ // own (a `merged_sha` written before merges recorded their own commit)
369
+ // never becomes it, and is remembered as such, bounded in count:
370
+ // without this, every list re-ran its git calls for every such row,
371
+ // serialized under the workspace mutex, on every poll, and a deployment
372
+ // with hundreds of pre-fix rows paid seconds of git per poll
373
+ // indefinitely. A commit the clone does not HOLD is not remembered —
374
+ // the next fetch may bring it, and the next list asks again.
375
+ if (this.mismatchedAppliedChanges.has(key)) return empty;
376
+ return this.gitService
377
+ .changedPathsAndPairsOfAppliedChange(workspaceId, source.applied)
378
+ .then((answer) => {
379
+ // Full: what is remembered stays, and this answer is simply not
380
+ // kept. A list scans every applied row in one pass, so clearing or
381
+ // evicting here would throw out entries the SAME pass is about to
382
+ // ask for again, and a deployment past the cap would recompute
383
+ // every row on every list. Kept, the rows past the cap are the only
384
+ // ones that cost git anything.
385
+ if (this.appliedChanges.size < MAX_REMEMBERED_APPLIED_CHANGES) this.appliedChanges.set(key, answer);
386
+ return answer;
387
+ })
388
+ .catch((err: unknown) => {
389
+ if (err instanceof AppliedChangeMismatchError) {
390
+ if (this.mismatchedAppliedChanges.size >= MAX_MISMATCHED_APPLIED_CHANGES) this.mismatchedAppliedChanges.clear();
391
+ this.mismatchedAppliedChanges.add(key);
392
+ }
393
+ return degrade('changedPathsAndPairsOfAppliedChange')(err);
394
+ });
395
+ }
193
396
  return this.gitService
194
- .changedPathsForPr(workspaceId, row.targetBranch, row.sourceBranch, opts)
195
- .catch((err) => {
196
- // Best-effort, but log it: an empty result silently hides a CR from the
197
- // owner-routing match in `listPrsForOwnerEmail`, so a swallowed failure
198
- // shouldn't be invisible.
199
- log.warn(
200
- `changedPathsForPr failed for #${row.number} (${row.sourceBranch} → ${row.targetBranch}) in ${workspaceId}:`,
201
- { err },
202
- );
203
- return [] as string[];
204
- });
397
+ .changedPathsAndPairsForPr(workspaceId, row.targetBranch, row.sourceBranch, opts)
398
+ .catch(degrade('changedPathsAndPairsForPr'));
205
399
  }
206
400
 
207
401
  /**
@@ -216,8 +410,14 @@ export class PullRequestService implements IPullRequestService {
216
410
  opts: { fetch?: boolean } = {},
217
411
  ): Promise<PullRequestSummary> {
218
412
  const touched = await this.touchedPathsFor(row, workspaceId, opts);
219
- const summary = this.rowToSummary(row, touched.filter((p) => !isFolderPlaceholder(p)));
220
- this.routingPaths.set(summary, touched);
413
+ const summary = this.rowToSummary(
414
+ row,
415
+ touched.paths.filter((p) => !isFolderPlaceholder(p)),
416
+ // Already placeholder-free and roles.yaml-free (see `changedPathPairs`),
417
+ // so the pairs need no filtering of their own here.
418
+ touched.pairs,
419
+ );
420
+ this.routingPaths.set(summary, touched.paths);
221
421
  return summary;
222
422
  }
223
423
 
@@ -274,6 +474,50 @@ export class PullRequestService implements IPullRequestService {
274
474
  return summaries;
275
475
  }
276
476
 
477
+ /**
478
+ * Every request in `states`, newest first. `['open']` is delegated so the
479
+ * list the app polls keeps its cache; any other set is read straight from
480
+ * the table, because a closed-request read is rare and a second cache keyed
481
+ * on a state set would mostly hold misses.
482
+ *
483
+ * Touched paths are best-effort exactly as on the open list, and no row costs
484
+ * a network call of its own: a MERGED row is diffed from the merge commit it
485
+ * records (local and immutable), and a DECLINED one is not diffed at all —
486
+ * see {@link touchedPathsFor}. A caller that gates on those paths must still
487
+ * treat an empty set as "cannot prove", never as "nothing to protect".
488
+ */
489
+ async listPrsByState(
490
+ states: PullRequestState[],
491
+ opts: { fresh?: boolean; workspaceId?: string } = {},
492
+ ): Promise<PullRequestSummary[]> {
493
+ const wanted = [...new Set(states)];
494
+ if (wanted.length === 0) return [];
495
+ if (wanted.length === 1 && wanted[0] === 'open') return this.listOpenPrs(opts);
496
+ const workspaceId = await this.resolveWorkspaceId(opts.workspaceId);
497
+ const rows = await this.db
498
+ .select()
499
+ .from(changeRequests)
500
+ .where(inArray(changeRequests.state, wanted))
501
+ .orderBy(desc(changeRequests.createdAt));
502
+ // ONE fetch for the whole list, for the reason spelled out on listOpenPrs —
503
+ // never one per row — and only when a row in scope reads from git at all.
504
+ // An open request is diffed from two branch refs, which are as current as
505
+ // the last fetch; a MERGED one from its merge commit, which cannot change
506
+ // but which a clone that has not fetched since the merge does not hold —
507
+ // without this fetch such a clone would read every applied request as
508
+ // author-only for good. A declined one reads from nothing, so a listing
509
+ // of declined requests alone reaches the network not once.
510
+ if (workspaceId && rows.some((row) => row.state === 'open' || row.state === 'merged')) {
511
+ await this.workspaceService
512
+ .ensureRemotesFetched(workspaceId, { force: opts.fresh === true })
513
+ .catch(() => undefined);
514
+ }
515
+ // Bounded fan-out: an applied row's read takes the workspace mutex for
516
+ // its git calls, so a hundred rows launched at once would only queue on
517
+ // it while holding a hundred pending promises.
518
+ return mapWithLimit(rows, SUMMARY_CONCURRENCY, (row) => this.summaryOf(row, workspaceId, { fetch: false }));
519
+ }
520
+
277
521
  async listPrsAuthoredBy(
278
522
  loginOrEmail: string,
279
523
  opts: { fresh?: boolean } = {},
@@ -448,42 +692,114 @@ export class PullRequestService implements IPullRequestService {
448
692
  };
449
693
  /** Did the target change, since the fork point, a file this request changes? */
450
694
  let targetChangedShared = false;
451
- if (workspaceId) {
452
- const shas = await this.gitService.resolvePrShas(
453
- workspaceId,
454
- row.targetBranch,
455
- row.sourceBranch,
456
- );
457
- baseSha = shas.baseSha;
458
- headSha = shas.headSha;
459
- // The file list is pinned to the SHAs just resolved (`at`), so it and
460
- // the `headSha` approvals pin against describe the same commits even if
461
- // another fetch lands on this workspace in between, and the second
462
- // fetch of the same two refs is gone. `patches: false` is for the
463
- // internal detail an approve / withdraw / revert fetches to pin its
464
- // work: those never read `files[].patch`, and generating it cost one git
465
- // subprocess per changed file per click. The detail served to clients
466
- // keeps its patches, so the published payload is unchanged.
467
- files = await this.gitService.changedFilesForPr(
468
- workspaceId,
469
- row.targetBranch,
470
- row.sourceBranch,
471
- { at: { baseSha, headSha }, ...(opts.patches === false ? { patchCap: 0 } : {}) },
472
- );
473
- // Pinned to the same two commits as the file list, so "needs updating"
474
- // and the diff it qualifies can never describe different heads.
475
- forkPoint = await this.gitService.forkPointForPr(workspaceId, { baseSha, headSha });
476
- // Only a target that moved in a file THIS request also changes makes the
477
- // proposal's diff describe text that has moved. One extra
478
- // `diff --name-only` (and only when the branches have diverged at all)
479
- // buys the difference between opening instantly and paying for a merge,
480
- // a push and a second detail read.
481
- targetChangedShared = await this.targetTouchesRequestFiles(
482
- workspaceId,
483
- forkPoint,
484
- baseSha,
485
- files,
695
+ // The SAME routing the list takes, from the same function, so the two
696
+ // surfaces cannot disagree about which files a request has — see
697
+ // {@link changeSourceFor} for why each state reads from what it does.
698
+ const source = workspaceId ? changeSourceFor(row) : ({ kind: 'none' } as ChangeSource);
699
+ try {
700
+ if (workspaceId && source.kind === 'commit') {
701
+ // An APPLIED request is read from its merge commit, not from its
702
+ // branches: the source branch is retired, so there is nothing to resolve
703
+ // and nothing to fetch. The commit's first parent is the target as it
704
+ // stood before the merge and its second is the source tip that landed,
705
+ // so the file list is exactly what was applied, and the `headSha` the
706
+ // approvals are judged stale against is the head they were given on.
707
+ //
708
+ // Before the 2026-10-02 decision this fell into the catch below and
709
+ // answered no files — which, since an empty file set proves no read
710
+ // access, made every applied request readable by its author alone.
711
+ // Reading back what happened is what the ticket exists for.
712
+ //
713
+ // One refresh first: the commit cannot change, but a clone that has
714
+ // not fetched since the merge does not hold it yet, and reading it as
715
+ // missing would make the request author-only on that clone for good.
716
+ await this.workspaceService
717
+ .ensureRemotesFetched(workspaceId, { force: opts.fresh === true })
718
+ .catch(() => undefined);
719
+ const ends = await this.gitService.appliedChangeShas(workspaceId, source.applied);
720
+ baseSha = ends.baseSha;
721
+ headSha = ends.headSha;
722
+ files = await this.gitService.changedFilesOfAppliedChange(workspaceId, source.applied, {
723
+ ...(opts.patches === false ? { patchCap: 0 } : {}),
724
+ });
725
+ // `forkPoint` and `targetChangedShared` stay at their defaults. "Is this
726
+ // behind its target, and does the divergence reach its files" is a
727
+ // question about a proposal that could still be updated; an applied one
728
+ // has no answer to give and reports none (`behind` and `needsUpdate` are
729
+ // gated on `state === 'open'` downstream anyway).
730
+ } else if (workspaceId && source.kind === 'branches') {
731
+ const shas = await this.gitService.resolvePrShas(
732
+ workspaceId,
733
+ row.targetBranch,
734
+ row.sourceBranch,
735
+ );
736
+ baseSha = shas.baseSha;
737
+ headSha = shas.headSha;
738
+ // The file list is pinned to the SHAs just resolved (`at`), so it and
739
+ // the `headSha` approvals pin against describe the same commits even if
740
+ // another fetch lands on this workspace in between, and the second
741
+ // fetch of the same two refs is gone. `patches: false` is for the
742
+ // internal detail an approve / withdraw / revert fetches to pin its
743
+ // work: those never read `files[].patch`, and generating it cost one git
744
+ // subprocess per changed file per click. The detail served to clients
745
+ // keeps its patches, so the published payload is unchanged.
746
+ files = await this.gitService.changedFilesForPr(
747
+ workspaceId,
748
+ row.targetBranch,
749
+ row.sourceBranch,
750
+ { at: { baseSha, headSha }, ...(opts.patches === false ? { patchCap: 0 } : {}) },
751
+ );
752
+ // Pinned to the same two commits as the file list, so "needs updating"
753
+ // and the diff it qualifies can never describe different heads.
754
+ forkPoint = await this.gitService.forkPointForPr(workspaceId, { baseSha, headSha });
755
+ // Only a target that moved in a file THIS request also changes makes the
756
+ // proposal's diff describe text that has moved. One extra
757
+ // `diff --name-only` (and only when the branches have diverged at all)
758
+ // buys the difference between opening instantly and paying for a merge,
759
+ // a push and a second detail read.
760
+ targetChangedShared = await this.targetTouchesRequestFiles(
761
+ workspaceId,
762
+ forkPoint,
763
+ baseSha,
764
+ files,
765
+ );
766
+ }
767
+ // `source.kind === 'none'` — a DECLINED request, a merged row recording no
768
+ // merge commit, or no workspace at all — asks git NOTHING and is answered
769
+ // from the ROW ALONE: no shas, no files. The row's STATE decides that, not
770
+ // whether git happens to fail, which is the fix for the contradiction
771
+ // Local Testing found: declining does not retire the source branch (only
772
+ // merging deletes it), so the branch pair below resolved a declined
773
+ // request perfectly well and published its files to anyone who could read
774
+ // one of them — while the list, which gives a declined row no paths at
775
+ // all, hid the same request from the same caller.
776
+ } catch (err) {
777
+ // What reaches this is a MERGED request whose merge commit this clone
778
+ // cannot resolve — not fetched yet, or a sha the row records that the
779
+ // object store does not hold. A DECLINED request never gets here at all
780
+ // any more: it asks git nothing (see above), so it cannot fail.
781
+ //
782
+ // Everything the ROW records — title, body, state, author, times — is
783
+ // still true, and this is the degradation the no-workspace case above
784
+ // already takes: no shas and no files, rather than no answer at all. Since
785
+ // an empty file set proves no read access, such a row fails CLOSED to its
786
+ // author, and the next read, once the commit is in the clone, answers in
787
+ // full.
788
+ //
789
+ // An OPEN request still fails loudly. There, an unresolvable branch means
790
+ // a branch not yet published or a clone not yet caught up, and presenting
791
+ // a live proposal as one that changes nothing would tell a reviewer the
792
+ // opposite of the truth.
793
+ if (!(err instanceof WorkflowValidationError) || row.state === 'open') throw err;
794
+ log.warn(
795
+ `change request #${prNumber} is ${row.state} and can no longer be diffed; answering from its row alone:`,
796
+ { err },
486
797
  );
798
+ baseSha = '';
799
+ headSha = '';
800
+ files = [];
801
+ forkPoint = { mergeBaseSha: null, behind: false };
802
+ targetChangedShared = false;
487
803
  }
488
804
 
489
805
  // Validated cache hit: TTL fresh AND head SHA unchanged since we cached.
@@ -499,7 +815,17 @@ export class PullRequestService implements IPullRequestService {
499
815
  return cached.value;
500
816
  }
501
817
 
502
- const summary = this.rowToSummary(row, files.map((f) => f.path));
818
+ // The detail has the real file list, so its pairs come straight off it —
819
+ // the same shape the list builds from its own diff, so a reader gating on
820
+ // `touchedNodeFiles` gets the same verdict from a summary and a detail.
821
+ const summary = this.rowToSummary(
822
+ row,
823
+ files.map((f) => f.path),
824
+ files.map((f) => ({
825
+ path: f.path,
826
+ ...(f.previousPath ? { previousPath: f.previousPath } : {}),
827
+ })),
828
+ );
503
829
 
504
830
  // Comments + approvals come from our own DB via the review-workflow service.
505
831
  // The enricher is optional — if it isn't wired yet (startup ordering,
@@ -4,7 +4,13 @@ import type { PullRequestFile } from '@bevel-software/platform-shared';
4
4
  // mergePr merges locally through GitService now (no `gh`), so we stub the git
5
5
  // seam and spy on it. `mergeChangeRequestMock` stands in for the real
6
6
  // merge+push; asserting whether it was called replaces the old `gh`-call checks.
7
- const mergeChangeRequestMock = vi.fn(async () => ({ kind: 'merged' as const, sha: 'merged-sha' }));
7
+ const mergeChangeRequestMock = vi.fn(async () => ({
8
+ kind: 'merged' as const,
9
+ sha: 'merged-sha',
10
+ // The commit the request owns — `sha` and `mergeCommit` coincide whenever the
11
+ // merge wrote one, which is the ordinary case these tests exercise.
12
+ mergeCommit: 'merged-sha',
13
+ }));
8
14
 
9
15
  import { ReviewWorkflowService } from '../review-workflow.service.js';
10
16
  import { changeRequests } from '../../../database/schema.js';