@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
@@ -1,7 +1,63 @@
1
- import type { FileApprovalState, IPullRequestService, PrReviewComment, PullRequestDetail, PullRequestFile, PullRequestState, PullRequestSummary, IGitService } from '@bevel-software/platform-shared';
1
+ import type { AppliedChangeRef, FileApprovalState, IPullRequestService, PrReviewComment, PullRequestDetail, PullRequestFile, PullRequestState, PullRequestSummary, IGitService } from '@bevel-software/platform-shared';
2
2
  import type { Database } from '../../database/connection.js';
3
3
  import type { WorkspaceService } from '../../workspace/workspace.service.js';
4
4
  import type { IAccessControl } from '../../access/access-control.interface.js';
5
+ /**
6
+ * Where a change request's files are read from.
7
+ *
8
+ * - `branches` — the source/target pair, as a live proposal is read.
9
+ * - `commit` — the merge commit the row records, as an applied one is.
10
+ * - `none` — nothing: there is no durable record of what this request
11
+ * proposed, so it has no file list to show.
12
+ */
13
+ export type ChangeSource = {
14
+ kind: 'branches';
15
+ } | {
16
+ kind: 'commit';
17
+ applied: AppliedChangeRef;
18
+ } | {
19
+ kind: 'none';
20
+ };
21
+ /**
22
+ * Which diff a change-request row is read from — asked ONCE, here, by both the
23
+ * list (`touchedPathsFor`) and the by-number detail (`getPrDetail`). The two
24
+ * surfaces answered this question separately before, and so answered it
25
+ * differently: the list gave a declined request no paths while the detail
26
+ * resolved its branch pair, which left `list_change_requests` and the four
27
+ * by-number tools contradicting each other about the same request for the same
28
+ * caller. One function, one answer.
29
+ *
30
+ * An OPEN request is its two branch tips, which is what a proposal IS.
31
+ *
32
+ * An APPLIED one is the merge commit its row records: the branch is retired, and
33
+ * the commit is local, immutable, and holds exactly what landed.
34
+ *
35
+ * A DECLINED one is read from NOTHING, and that is not a degradation:
36
+ *
37
+ * - The row records no sha, so nothing durable says what the request
38
+ * proposed. Declining retires the source branch as merging does
39
+ * (`deleteChangeRequest` ends in `retireMergedSourceBranch`) — unless
40
+ * another open request still needs that branch, which keeps it alive and
41
+ * moving. Where the branch pair still resolves it would answer with what
42
+ * that branch differs by NOW, work committed since for the request that
43
+ * kept it included; reading the declined request would then present later
44
+ * work as the proposal that was turned down.
45
+ * - An empty file set proves no read access downstream, so a declined request
46
+ * is readable by its author alone — the owner's criterion of 2026-10-02.
47
+ * - And it asks git nothing, so neither a listing nor a by-number read of a
48
+ * declined request costs a network round trip.
49
+ *
50
+ * A merged row that records no merge commit (or whose commit this clone has not
51
+ * fetched) lands on `none` too, and fails closed the same way until it can be
52
+ * read in full.
53
+ */
54
+ export declare function changeSourceFor(row: {
55
+ number: number;
56
+ state: string;
57
+ /** The stored title, handed along so a merge commit in the old message format is still recognised. */
58
+ title?: string;
59
+ mergedSha?: string | null;
60
+ }): ChangeSource;
5
61
  /**
6
62
  * The slice of the review-workflow service this module needs to compose detail
7
63
  * responses. Kept narrow (just what `getPrDetail` stitches in) so the PR
@@ -64,6 +120,14 @@ export declare class PullRequestService implements IPullRequestService {
64
120
  * list cache stores, so it lives and dies with them.
65
121
  */
66
122
  private routingPaths;
123
+ /**
124
+ * The files an APPLIED request landed, per clone and merge commit. A merge
125
+ * commit is immutable, so its answer is good for the life of the process;
126
+ * nothing invalidates this, and nothing needs to. See {@link touchedPathsFor}.
127
+ */
128
+ private readonly appliedChanges;
129
+ /** Applied rows whose recorded commit is not their own, by the same key — never asked again. */
130
+ private readonly mismatchedAppliedChanges;
67
131
  /**
68
132
  * Per-CR detail cache, keyed by `${workspaceId ?? 'global'}:${viewer}:${number}`.
69
133
  * The payload includes per-file approvals resolved against the caller's
@@ -101,6 +165,19 @@ export declare class PullRequestService implements IPullRequestService {
101
165
  /**
102
166
  * Cheap touched-paths for a CR row (empty when no workspace exists yet),
103
167
  * placeholders included — see {@link summaryOf} for what a summary shows.
168
+ *
169
+ * WHICH DIFF is {@link changeSourceFor}'s answer — the same one `getPrDetail`
170
+ * takes, so a list and a by-number read never disagree about which files a
171
+ * request has. No state reaches the network per request: an open row is
172
+ * diffed from two branch tips a single whole-clone refresh has already brought
173
+ * up to date (`fetch: false`), an applied one from a local immutable commit,
174
+ * and a declined one from nothing at all.
175
+ *
176
+ * The merge commit also undid the flood finding 1 of Razvan's review named:
177
+ * `publishedPrCommits` answers null for a retired branch, which sent
178
+ * `changedPathsAndPairsForPr` to `fetch` two refs — one of them gone — once PER
179
+ * REQUEST and then logged a warning for each, so a list of a few hundred
180
+ * applied requests opened a few hundred concurrent fetches to answer nothing.
104
181
  */
105
182
  private touchedPathsFor;
106
183
  /**
@@ -114,6 +191,22 @@ export declare class PullRequestService implements IPullRequestService {
114
191
  fresh?: boolean;
115
192
  workspaceId?: string;
116
193
  }): Promise<PullRequestSummary[]>;
194
+ /**
195
+ * Every request in `states`, newest first. `['open']` is delegated so the
196
+ * list the app polls keeps its cache; any other set is read straight from
197
+ * the table, because a closed-request read is rare and a second cache keyed
198
+ * on a state set would mostly hold misses.
199
+ *
200
+ * Touched paths are best-effort exactly as on the open list, and no row costs
201
+ * a network call of its own: a MERGED row is diffed from the merge commit it
202
+ * records (local and immutable), and a DECLINED one is not diffed at all —
203
+ * see {@link touchedPathsFor}. A caller that gates on those paths must still
204
+ * treat an empty set as "cannot prove", never as "nothing to protect".
205
+ */
206
+ listPrsByState(states: PullRequestState[], opts?: {
207
+ fresh?: boolean;
208
+ workspaceId?: string;
209
+ }): Promise<PullRequestSummary[]>;
117
210
  listPrsAuthoredBy(loginOrEmail: string, opts?: {
118
211
  fresh?: boolean;
119
212
  }): Promise<PullRequestSummary[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"pull-request.service.d.ts","sourceRoot":"","sources":["../../../../src/modules/workflow/git/pull-request.service.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,iBAAiB,EACjB,mBAAmB,EACnB,eAAe,EACf,iBAAiB,EACjB,eAAe,EACf,gBAAgB,EAChB,kBAAkB,EAClB,WAAW,EACZ,MAAM,iCAAiC,CAAC;AAEzC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AAE7D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AAC7E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0CAA0C,CAAC;AAW/E;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;IAC3D;;;;;;OAMG;IACH,iBAAiB,CACf,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,eAAe,EAAE,EACxB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,cAAc,EAAE,MAAM,GAAG,IAAI,EAC7B,WAAW,CAAC,EAAE,MAAM,EACpB,WAAW,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;IAChC;;;OAGG;IACH,iBAAiB,CAAC,KAAK,EAAE;QACvB,QAAQ,EAAE,MAAM,CAAC;QACjB,KAAK,EAAE,gBAAgB,CAAC;QACxB,SAAS,EAAE,iBAAiB,EAAE,CAAC;KAChC,GAAG;QAAE,SAAS,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;CACnE;AAED;;;;;;;;;;GAUG;AACH,qBAAa,kBAAmB,YAAW,mBAAmB;IAgD1D,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAlD7B;;;;;;OAMG;IACH,OAAO,CAAC,UAAU,CAAkE;IACpF;;;;;;OAMG;IACH,OAAO,CAAC,YAAY,CAA+C;IACnE;;;;;;OAMG;IACH,OAAO,CAAC,WAAW,CAGf;IAEJ;;;;;OAKG;IACH,OAAO,CAAC,cAAc,CAAiC;IAEvD,4FAA4F;IAC5F,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgB;IAEzC;;;;OAIG;IACH,OAAO,CAAC,eAAe,CAAK;gBAGT,EAAE,EAAE,QAAQ,EACZ,gBAAgB,EAAE,gBAAgB,EAClC,aAAa,EAAE,cAAc,EAC7B,UAAU,EAAE,WAAW;IACxC,wFAAwF;IACxF,iBAAiB,GAAE,MAAM,GAAG,IAAW;IAKzC,iBAAiB,CAAC,QAAQ,EAAE,gBAAgB,GAAG,IAAI;IAInD;;;;OAIG;YACW,kBAAkB;IAKhC,OAAO,CAAC,YAAY;IAmCpB;;;OAGG;YACW,eAAe;IAoB7B;;;;;OAKG;YACW,SAAS;IAWjB,WAAW,CACf,IAAI,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAO,GACnD,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAmD1B,iBAAiB,CACrB,YAAY,EAAE,MAAM,EACpB,IAAI,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO,GAC7B,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAY1B,oBAAoB,CACxB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,MAAM,EACb,IAAI,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO,GAC7B,OAAO,CAAC,kBAAkB,EAAE,CAAC;IA+D1B,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IAUjE;;;;;;;;;;;;;;;;;;OAkBG;YACW,yBAAyB;IAmCjC,WAAW,CACf,QAAQ,EAAE,MAAM,EAChB,IAAI,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,OAAO,CAAA;KAAO,GAC5F,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;IAgNpC;;;;OAIG;IACH,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAS7C;;;;OAIG;IACH,mBAAmB,IAAI,IAAI;YAKb,OAAO;CAQtB;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE;IAC5C,KAAK,EAAE,gBAAgB,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,oBAAoB,EAAE,OAAO,CAAC;IAC9B,oBAAoB,EAAE,OAAO,CAAC;CAC/B,GAAG,OAAO,CAKV;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE;IAC5C,KAAK,EAAE,gBAAgB,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,oBAAoB,EAAE,OAAO,CAAC;IAC9B,SAAS,EAAE,iBAAiB,EAAE,CAAC;CAChC,GAAG,OAAO,CAUV;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE;IAC5C,KAAK,EAAE,gBAAgB,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,oBAAoB,EAAE,OAAO,CAAC;CAC/B,GAAG,OAAO,CAKV;AAED,eAAO,MAAM,SAAS;;;;CAIrB,CAAC"}
1
+ {"version":3,"file":"pull-request.service.d.ts","sourceRoot":"","sources":["../../../../src/modules/workflow/git/pull-request.service.ts"],"names":[],"mappings":"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"}
@@ -1,14 +1,101 @@
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
  const log = logger('cr');
4
4
  import { isFolderPlaceholder } from '@bevel-software/platform-shared';
5
5
  import { changeRequests } from '../../database/schema.js';
6
6
  import { AccessUnreadableError } from '../../access-model/access-errors.js';
7
- import { WorkflowValidationError } from '../../../shared/domain-errors.js';
7
+ import { AppliedChangeMismatchError, WorkflowValidationError } from '../../../shared/domain-errors.js';
8
8
  import { canonicalEmail, hashEmail } from '../../../shared/email-identity.js';
9
9
  import { changeRequestLink, changeRequestLinkBase } from './change-request-link.js';
10
+ /**
11
+ * The latest moment a change-request row records — its close time when it has
12
+ * one, else its creation time, with `updated_at` folded in for the day
13
+ * something writes it. Deliberately derived from the ROW alone: a time taken
14
+ * from the newest comment or approval would make the same request report
15
+ * different "last changed" moments to a list (which reads no comments) and a
16
+ * detail (which does).
17
+ */
18
+ function latestRowMoment(row) {
19
+ const times = [row.createdAt, row.updatedAt, row.closedAt]
20
+ .filter((d) => d instanceof Date)
21
+ .map((d) => d.getTime());
22
+ return new Date(Math.max(...times)).toISOString();
23
+ }
10
24
  const LIST_PR_CACHE_TTL_MS = 30_000;
25
+ /**
26
+ * How many applied requests' file lists are remembered at once. Each is a
27
+ * short path list, so this is about a deployment that applies requests for
28
+ * years never growing the map without bound. Past it, what is remembered
29
+ * stays and new answers are not kept — see `touchedPathsFor` for why nothing
30
+ * is evicted.
31
+ */
32
+ const MAX_REMEMBERED_APPLIED_CHANGES = 20_000;
33
+ /** How many mismatched applied rows to remember — see `touchedPathsFor`. */
34
+ const MAX_MISMATCHED_APPLIED_CHANGES = 5_000;
35
+ /** How many rows a listing reads from git at once. */
36
+ const SUMMARY_CONCURRENCY = 8;
37
+ /** `fn` over `items`, at most `limit` in flight, results in order. */
38
+ async function mapWithLimit(items, limit, fn) {
39
+ const results = new Array(items.length);
40
+ let next = 0;
41
+ const worker = async () => {
42
+ while (next < items.length) {
43
+ const index = next;
44
+ next += 1;
45
+ results[index] = await fn(items[index]);
46
+ }
47
+ };
48
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
49
+ return results;
50
+ }
11
51
  const DETAIL_CACHE_TTL_MS = 30_000;
52
+ /**
53
+ * Which diff a change-request row is read from — asked ONCE, here, by both the
54
+ * list (`touchedPathsFor`) and the by-number detail (`getPrDetail`). The two
55
+ * surfaces answered this question separately before, and so answered it
56
+ * differently: the list gave a declined request no paths while the detail
57
+ * resolved its branch pair, which left `list_change_requests` and the four
58
+ * by-number tools contradicting each other about the same request for the same
59
+ * caller. One function, one answer.
60
+ *
61
+ * An OPEN request is its two branch tips, which is what a proposal IS.
62
+ *
63
+ * An APPLIED one is the merge commit its row records: the branch is retired, and
64
+ * the commit is local, immutable, and holds exactly what landed.
65
+ *
66
+ * A DECLINED one is read from NOTHING, and that is not a degradation:
67
+ *
68
+ * - The row records no sha, so nothing durable says what the request
69
+ * proposed. Declining retires the source branch as merging does
70
+ * (`deleteChangeRequest` ends in `retireMergedSourceBranch`) — unless
71
+ * another open request still needs that branch, which keeps it alive and
72
+ * moving. Where the branch pair still resolves it would answer with what
73
+ * that branch differs by NOW, work committed since for the request that
74
+ * kept it included; reading the declined request would then present later
75
+ * work as the proposal that was turned down.
76
+ * - An empty file set proves no read access downstream, so a declined request
77
+ * is readable by its author alone — the owner's criterion of 2026-10-02.
78
+ * - And it asks git nothing, so neither a listing nor a by-number read of a
79
+ * declined request costs a network round trip.
80
+ *
81
+ * A merged row that records no merge commit (or whose commit this clone has not
82
+ * fetched) lands on `none` too, and fails closed the same way until it can be
83
+ * read in full.
84
+ */
85
+ export function changeSourceFor(row) {
86
+ if (row.state === 'open')
87
+ return { kind: 'branches' };
88
+ // The NUMBER travels with the sha, because reading the commit's own change is
89
+ // only sound if the commit is this request's merge commit — and the number is
90
+ // what proves it (the subject ends with `(#<number>)`). A row whose
91
+ // `merged_sha` was written by a merge that made no commit points at the target
92
+ // tip, which is usually another request's merge commit; the git layer rejects
93
+ // that rather than answering with its files. See `merge-commit.ts`.
94
+ if (row.state === 'merged' && row.mergedSha) {
95
+ return { kind: 'commit', applied: { number: row.number, mergeSha: row.mergedSha, title: row.title } };
96
+ }
97
+ return { kind: 'none' };
98
+ }
12
99
  /**
13
100
  * Reads change requests from the app's own DB (`change_requests`) and computes
14
101
  * their diff + SHAs locally from git — no provider PR API. This is what lets the
@@ -41,6 +128,14 @@ export class PullRequestService {
41
128
  * list cache stores, so it lives and dies with them.
42
129
  */
43
130
  routingPaths = new WeakMap();
131
+ /**
132
+ * The files an APPLIED request landed, per clone and merge commit. A merge
133
+ * commit is immutable, so its answer is good for the life of the process;
134
+ * nothing invalidates this, and nothing needs to. See {@link touchedPathsFor}.
135
+ */
136
+ appliedChanges = new Map();
137
+ /** Applied rows whose recorded commit is not their own, by the same key — never asked again. */
138
+ mismatchedAppliedChanges = new Set();
44
139
  /**
45
140
  * Per-CR detail cache, keyed by `${workspaceId ?? 'global'}:${viewer}:${number}`.
46
141
  * The payload includes per-file approvals resolved against the caller's
@@ -86,7 +181,7 @@ export class PullRequestService {
86
181
  return preferred;
87
182
  return this.workspaceService.findAnyWorkspaceId();
88
183
  }
89
- rowToSummary(row, touchedNodePaths) {
184
+ rowToSummary(row, touchedNodePaths, touchedNodeFiles) {
90
185
  return {
91
186
  number: row.number,
92
187
  title: row.title,
@@ -101,7 +196,14 @@ export class PullRequestService {
101
196
  base: row.targetBranch,
102
197
  state: row.state,
103
198
  createdAt: row.createdAt.toISOString(),
199
+ // The latest moment the ROW records. Nothing stamps `updated_at` on a
200
+ // change request today, so in practice this is the close time of a
201
+ // closed request and the creation time of an open one — the honest
202
+ // answer from the row, with the column folded in for the day something
203
+ // does write it.
204
+ updatedAt: latestRowMoment(row),
104
205
  touchedNodePaths,
206
+ touchedNodeFiles,
105
207
  // Provider reviews are gone; the real approval state lives in the detail
106
208
  // view (per-file, DB-backed). The summary badge is derived there.
107
209
  review: { approvals: 0, changesRequested: 0, pendingLogins: [] },
@@ -122,19 +224,89 @@ export class PullRequestService {
122
224
  /**
123
225
  * Cheap touched-paths for a CR row (empty when no workspace exists yet),
124
226
  * placeholders included — see {@link summaryOf} for what a summary shows.
227
+ *
228
+ * WHICH DIFF is {@link changeSourceFor}'s answer — the same one `getPrDetail`
229
+ * takes, so a list and a by-number read never disagree about which files a
230
+ * request has. No state reaches the network per request: an open row is
231
+ * diffed from two branch tips a single whole-clone refresh has already brought
232
+ * up to date (`fetch: false`), an applied one from a local immutable commit,
233
+ * and a declined one from nothing at all.
234
+ *
235
+ * The merge commit also undid the flood finding 1 of Razvan's review named:
236
+ * `publishedPrCommits` answers null for a retired branch, which sent
237
+ * `changedPathsAndPairsForPr` to `fetch` two refs — one of them gone — once PER
238
+ * REQUEST and then logged a warning for each, so a list of a few hundred
239
+ * applied requests opened a few hundred concurrent fetches to answer nothing.
125
240
  */
126
241
  async touchedPathsFor(row, workspaceId, opts = {}) {
242
+ const empty = { paths: [], pairs: [] };
127
243
  if (!workspaceId)
128
- return [];
244
+ return empty;
245
+ // Best-effort, but logged: an empty result silently hides a CR from the
246
+ // owner-routing match in `listPrsForOwnerEmail`, so a swallowed failure
247
+ // shouldn't be invisible.
248
+ const degrade = (what) => (err) => {
249
+ const where = `#${row.number} (${row.sourceBranch} → ${row.targetBranch}) in ${workspaceId}`;
250
+ // A clone that simply does not hold the merge commit yet is not a failure
251
+ // to shout about — it is this row's turn to be fetched, and the next list
252
+ // read answers it. Warning per request on that would be the log flood the
253
+ // network flood came with.
254
+ if (err instanceof WorkflowValidationError) {
255
+ log.debug(`${what} could not resolve ${where}; answering no touched paths:`, { err });
256
+ }
257
+ else {
258
+ log.warn(`${what} failed for ${where}:`, { err });
259
+ }
260
+ return empty;
261
+ };
262
+ const source = changeSourceFor(row);
263
+ if (source.kind === 'none')
264
+ return empty;
265
+ if (source.kind === 'commit') {
266
+ // Remembered once read: a merge commit never changes, so neither does
267
+ // its diff, and a list of a few hundred applied requests would otherwise
268
+ // run four git processes per row on every call. Only an ANSWER is kept.
269
+ // A commit this clone does not hold yet rejects, and that is this row's
270
+ // turn to be fetched, so the next read asks again.
271
+ const key = `${workspaceId}\u0000${source.applied.mergeSha}\u0000${source.applied.number}`;
272
+ const known = this.appliedChanges.get(key);
273
+ if (known)
274
+ return known;
275
+ // A row whose commit the clone holds but which is NOT this request's
276
+ // own (a `merged_sha` written before merges recorded their own commit)
277
+ // never becomes it, and is remembered as such, bounded in count:
278
+ // without this, every list re-ran its git calls for every such row,
279
+ // serialized under the workspace mutex, on every poll, and a deployment
280
+ // with hundreds of pre-fix rows paid seconds of git per poll
281
+ // indefinitely. A commit the clone does not HOLD is not remembered —
282
+ // the next fetch may bring it, and the next list asks again.
283
+ if (this.mismatchedAppliedChanges.has(key))
284
+ return empty;
285
+ return this.gitService
286
+ .changedPathsAndPairsOfAppliedChange(workspaceId, source.applied)
287
+ .then((answer) => {
288
+ // Full: what is remembered stays, and this answer is simply not
289
+ // kept. A list scans every applied row in one pass, so clearing or
290
+ // evicting here would throw out entries the SAME pass is about to
291
+ // ask for again, and a deployment past the cap would recompute
292
+ // every row on every list. Kept, the rows past the cap are the only
293
+ // ones that cost git anything.
294
+ if (this.appliedChanges.size < MAX_REMEMBERED_APPLIED_CHANGES)
295
+ this.appliedChanges.set(key, answer);
296
+ return answer;
297
+ })
298
+ .catch((err) => {
299
+ if (err instanceof AppliedChangeMismatchError) {
300
+ if (this.mismatchedAppliedChanges.size >= MAX_MISMATCHED_APPLIED_CHANGES)
301
+ this.mismatchedAppliedChanges.clear();
302
+ this.mismatchedAppliedChanges.add(key);
303
+ }
304
+ return degrade('changedPathsAndPairsOfAppliedChange')(err);
305
+ });
306
+ }
129
307
  return this.gitService
130
- .changedPathsForPr(workspaceId, row.targetBranch, row.sourceBranch, opts)
131
- .catch((err) => {
132
- // Best-effort, but log it: an empty result silently hides a CR from the
133
- // owner-routing match in `listPrsForOwnerEmail`, so a swallowed failure
134
- // shouldn't be invisible.
135
- log.warn(`changedPathsForPr failed for #${row.number} (${row.sourceBranch} → ${row.targetBranch}) in ${workspaceId}:`, { err });
136
- return [];
137
- });
308
+ .changedPathsAndPairsForPr(workspaceId, row.targetBranch, row.sourceBranch, opts)
309
+ .catch(degrade('changedPathsAndPairsForPr'));
138
310
  }
139
311
  /**
140
312
  * The summary of a CR row. The empty-folder placeholder is never content,
@@ -144,8 +316,11 @@ export class PullRequestService {
144
316
  */
145
317
  async summaryOf(row, workspaceId, opts = {}) {
146
318
  const touched = await this.touchedPathsFor(row, workspaceId, opts);
147
- const summary = this.rowToSummary(row, touched.filter((p) => !isFolderPlaceholder(p)));
148
- this.routingPaths.set(summary, touched);
319
+ const summary = this.rowToSummary(row, touched.paths.filter((p) => !isFolderPlaceholder(p)),
320
+ // Already placeholder-free and roles.yaml-free (see `changedPathPairs`),
321
+ // so the pairs need no filtering of their own here.
322
+ touched.pairs);
323
+ this.routingPaths.set(summary, touched.paths);
149
324
  return summary;
150
325
  }
151
326
  async listOpenPrs(opts = {}) {
@@ -196,6 +371,48 @@ export class PullRequestService {
196
371
  }
197
372
  return summaries;
198
373
  }
374
+ /**
375
+ * Every request in `states`, newest first. `['open']` is delegated so the
376
+ * list the app polls keeps its cache; any other set is read straight from
377
+ * the table, because a closed-request read is rare and a second cache keyed
378
+ * on a state set would mostly hold misses.
379
+ *
380
+ * Touched paths are best-effort exactly as on the open list, and no row costs
381
+ * a network call of its own: a MERGED row is diffed from the merge commit it
382
+ * records (local and immutable), and a DECLINED one is not diffed at all —
383
+ * see {@link touchedPathsFor}. A caller that gates on those paths must still
384
+ * treat an empty set as "cannot prove", never as "nothing to protect".
385
+ */
386
+ async listPrsByState(states, opts = {}) {
387
+ const wanted = [...new Set(states)];
388
+ if (wanted.length === 0)
389
+ return [];
390
+ if (wanted.length === 1 && wanted[0] === 'open')
391
+ return this.listOpenPrs(opts);
392
+ const workspaceId = await this.resolveWorkspaceId(opts.workspaceId);
393
+ const rows = await this.db
394
+ .select()
395
+ .from(changeRequests)
396
+ .where(inArray(changeRequests.state, wanted))
397
+ .orderBy(desc(changeRequests.createdAt));
398
+ // ONE fetch for the whole list, for the reason spelled out on listOpenPrs —
399
+ // never one per row — and only when a row in scope reads from git at all.
400
+ // An open request is diffed from two branch refs, which are as current as
401
+ // the last fetch; a MERGED one from its merge commit, which cannot change
402
+ // but which a clone that has not fetched since the merge does not hold —
403
+ // without this fetch such a clone would read every applied request as
404
+ // author-only for good. A declined one reads from nothing, so a listing
405
+ // of declined requests alone reaches the network not once.
406
+ if (workspaceId && rows.some((row) => row.state === 'open' || row.state === 'merged')) {
407
+ await this.workspaceService
408
+ .ensureRemotesFetched(workspaceId, { force: opts.fresh === true })
409
+ .catch(() => undefined);
410
+ }
411
+ // Bounded fan-out: an applied row's read takes the workspace mutex for
412
+ // its git calls, so a hundred rows launched at once would only queue on
413
+ // it while holding a hundred pending promises.
414
+ return mapWithLimit(rows, SUMMARY_CONCURRENCY, (row) => this.summaryOf(row, workspaceId, { fetch: false }));
415
+ }
199
416
  async listPrsAuthoredBy(loginOrEmail, opts = {}) {
200
417
  const needle = canonicalEmail(loginOrEmail);
201
418
  if (!needle)
@@ -346,28 +563,100 @@ export class PullRequestService {
346
563
  };
347
564
  /** Did the target change, since the fork point, a file this request changes? */
348
565
  let targetChangedShared = false;
349
- if (workspaceId) {
350
- const shas = await this.gitService.resolvePrShas(workspaceId, row.targetBranch, row.sourceBranch);
351
- baseSha = shas.baseSha;
352
- headSha = shas.headSha;
353
- // The file list is pinned to the SHAs just resolved (`at`), so it and
354
- // the `headSha` approvals pin against describe the same commits even if
355
- // another fetch lands on this workspace in between, and the second
356
- // fetch of the same two refs is gone. `patches: false` is for the
357
- // internal detail an approve / withdraw / revert fetches to pin its
358
- // work: those never read `files[].patch`, and generating it cost one git
359
- // subprocess per changed file per click. The detail served to clients
360
- // keeps its patches, so the published payload is unchanged.
361
- files = await this.gitService.changedFilesForPr(workspaceId, row.targetBranch, row.sourceBranch, { at: { baseSha, headSha }, ...(opts.patches === false ? { patchCap: 0 } : {}) });
362
- // Pinned to the same two commits as the file list, so "needs updating"
363
- // and the diff it qualifies can never describe different heads.
364
- forkPoint = await this.gitService.forkPointForPr(workspaceId, { baseSha, headSha });
365
- // Only a target that moved in a file THIS request also changes makes the
366
- // proposal's diff describe text that has moved. One extra
367
- // `diff --name-only` (and only when the branches have diverged at all)
368
- // buys the difference between opening instantly and paying for a merge,
369
- // a push and a second detail read.
370
- targetChangedShared = await this.targetTouchesRequestFiles(workspaceId, forkPoint, baseSha, files);
566
+ // The SAME routing the list takes, from the same function, so the two
567
+ // surfaces cannot disagree about which files a request has — see
568
+ // {@link changeSourceFor} for why each state reads from what it does.
569
+ const source = workspaceId ? changeSourceFor(row) : { kind: 'none' };
570
+ try {
571
+ if (workspaceId && source.kind === 'commit') {
572
+ // An APPLIED request is read from its merge commit, not from its
573
+ // branches: the source branch is retired, so there is nothing to resolve
574
+ // and nothing to fetch. The commit's first parent is the target as it
575
+ // stood before the merge and its second is the source tip that landed,
576
+ // so the file list is exactly what was applied, and the `headSha` the
577
+ // approvals are judged stale against is the head they were given on.
578
+ //
579
+ // Before the 2026-10-02 decision this fell into the catch below and
580
+ // answered no files — which, since an empty file set proves no read
581
+ // access, made every applied request readable by its author alone.
582
+ // Reading back what happened is what the ticket exists for.
583
+ //
584
+ // One refresh first: the commit cannot change, but a clone that has
585
+ // not fetched since the merge does not hold it yet, and reading it as
586
+ // missing would make the request author-only on that clone for good.
587
+ await this.workspaceService
588
+ .ensureRemotesFetched(workspaceId, { force: opts.fresh === true })
589
+ .catch(() => undefined);
590
+ const ends = await this.gitService.appliedChangeShas(workspaceId, source.applied);
591
+ baseSha = ends.baseSha;
592
+ headSha = ends.headSha;
593
+ files = await this.gitService.changedFilesOfAppliedChange(workspaceId, source.applied, {
594
+ ...(opts.patches === false ? { patchCap: 0 } : {}),
595
+ });
596
+ // `forkPoint` and `targetChangedShared` stay at their defaults. "Is this
597
+ // behind its target, and does the divergence reach its files" is a
598
+ // question about a proposal that could still be updated; an applied one
599
+ // has no answer to give and reports none (`behind` and `needsUpdate` are
600
+ // gated on `state === 'open'` downstream anyway).
601
+ }
602
+ else if (workspaceId && source.kind === 'branches') {
603
+ const shas = await this.gitService.resolvePrShas(workspaceId, row.targetBranch, row.sourceBranch);
604
+ baseSha = shas.baseSha;
605
+ headSha = shas.headSha;
606
+ // The file list is pinned to the SHAs just resolved (`at`), so it and
607
+ // the `headSha` approvals pin against describe the same commits even if
608
+ // another fetch lands on this workspace in between, and the second
609
+ // fetch of the same two refs is gone. `patches: false` is for the
610
+ // internal detail an approve / withdraw / revert fetches to pin its
611
+ // work: those never read `files[].patch`, and generating it cost one git
612
+ // subprocess per changed file per click. The detail served to clients
613
+ // keeps its patches, so the published payload is unchanged.
614
+ files = await this.gitService.changedFilesForPr(workspaceId, row.targetBranch, row.sourceBranch, { at: { baseSha, headSha }, ...(opts.patches === false ? { patchCap: 0 } : {}) });
615
+ // Pinned to the same two commits as the file list, so "needs updating"
616
+ // and the diff it qualifies can never describe different heads.
617
+ forkPoint = await this.gitService.forkPointForPr(workspaceId, { baseSha, headSha });
618
+ // Only a target that moved in a file THIS request also changes makes the
619
+ // proposal's diff describe text that has moved. One extra
620
+ // `diff --name-only` (and only when the branches have diverged at all)
621
+ // buys the difference between opening instantly and paying for a merge,
622
+ // a push and a second detail read.
623
+ targetChangedShared = await this.targetTouchesRequestFiles(workspaceId, forkPoint, baseSha, files);
624
+ }
625
+ // `source.kind === 'none'` — a DECLINED request, a merged row recording no
626
+ // merge commit, or no workspace at all — asks git NOTHING and is answered
627
+ // from the ROW ALONE: no shas, no files. The row's STATE decides that, not
628
+ // whether git happens to fail, which is the fix for the contradiction
629
+ // Local Testing found: declining does not retire the source branch (only
630
+ // merging deletes it), so the branch pair below resolved a declined
631
+ // request perfectly well and published its files to anyone who could read
632
+ // one of them — while the list, which gives a declined row no paths at
633
+ // all, hid the same request from the same caller.
634
+ }
635
+ catch (err) {
636
+ // What reaches this is a MERGED request whose merge commit this clone
637
+ // cannot resolve — not fetched yet, or a sha the row records that the
638
+ // object store does not hold. A DECLINED request never gets here at all
639
+ // any more: it asks git nothing (see above), so it cannot fail.
640
+ //
641
+ // Everything the ROW records — title, body, state, author, times — is
642
+ // still true, and this is the degradation the no-workspace case above
643
+ // already takes: no shas and no files, rather than no answer at all. Since
644
+ // an empty file set proves no read access, such a row fails CLOSED to its
645
+ // author, and the next read, once the commit is in the clone, answers in
646
+ // full.
647
+ //
648
+ // An OPEN request still fails loudly. There, an unresolvable branch means
649
+ // a branch not yet published or a clone not yet caught up, and presenting
650
+ // a live proposal as one that changes nothing would tell a reviewer the
651
+ // opposite of the truth.
652
+ if (!(err instanceof WorkflowValidationError) || row.state === 'open')
653
+ throw err;
654
+ log.warn(`change request #${prNumber} is ${row.state} and can no longer be diffed; answering from its row alone:`, { err });
655
+ baseSha = '';
656
+ headSha = '';
657
+ files = [];
658
+ forkPoint = { mergeBaseSha: null, behind: false };
659
+ targetChangedShared = false;
371
660
  }
372
661
  // Validated cache hit: TTL fresh AND head SHA unchanged since we cached.
373
662
  const cached = this.detailCache.get(cacheKey);
@@ -379,7 +668,13 @@ export class PullRequestService {
379
668
  cached.baseSha === baseSha) {
380
669
  return cached.value;
381
670
  }
382
- const summary = this.rowToSummary(row, files.map((f) => f.path));
671
+ // The detail has the real file list, so its pairs come straight off it —
672
+ // the same shape the list builds from its own diff, so a reader gating on
673
+ // `touchedNodeFiles` gets the same verdict from a summary and a detail.
674
+ const summary = this.rowToSummary(row, files.map((f) => f.path), files.map((f) => ({
675
+ path: f.path,
676
+ ...(f.previousPath ? { previousPath: f.previousPath } : {}),
677
+ })));
383
678
  // Comments + approvals come from our own DB via the review-workflow service.
384
679
  // The enricher is optional — if it isn't wired yet (startup ordering,
385
680
  // isolated tests) we return empty lists rather than blocking the fetch.