@bevel-software/platform-core-backend 0.1.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 (720) hide show
  1. package/LICENSE +202 -0
  2. package/dist/assets.d.ts +5 -0
  3. package/dist/assets.d.ts.map +1 -0
  4. package/dist/assets.js +23 -0
  5. package/dist/assets.js.map +1 -0
  6. package/dist/core/core-ports.d.ts +83 -0
  7. package/dist/core/core-ports.d.ts.map +1 -0
  8. package/dist/core/core-ports.js +12 -0
  9. package/dist/core/core-ports.js.map +1 -0
  10. package/dist/core/create-core-server.d.ts +62 -0
  11. package/dist/core/create-core-server.d.ts.map +1 -0
  12. package/dist/core/create-core-server.js +204 -0
  13. package/dist/core/create-core-server.js.map +1 -0
  14. package/dist/core/create-core-services.d.ts +95 -0
  15. package/dist/core/create-core-services.d.ts.map +1 -0
  16. package/dist/core/create-core-services.js +352 -0
  17. package/dist/core/create-core-services.js.map +1 -0
  18. package/dist/core-config.d.ts +119 -0
  19. package/dist/core-config.d.ts.map +1 -0
  20. package/dist/core-config.js +240 -0
  21. package/dist/core-config.js.map +1 -0
  22. package/dist/index.d.ts +36 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +36 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/modules/access/access-control.interface.d.ts +348 -0
  27. package/dist/modules/access/access-control.interface.d.ts.map +1 -0
  28. package/dist/modules/access/access-control.interface.js +2 -0
  29. package/dist/modules/access/access-control.interface.js.map +1 -0
  30. package/dist/modules/access/access-control.service.d.ts +323 -0
  31. package/dist/modules/access/access-control.service.d.ts.map +1 -0
  32. package/dist/modules/access/access-control.service.js +1431 -0
  33. package/dist/modules/access/access-control.service.js.map +1 -0
  34. package/dist/modules/access/access-errors.d.ts +34 -0
  35. package/dist/modules/access/access-errors.d.ts.map +1 -0
  36. package/dist/modules/access/access-errors.js +40 -0
  37. package/dist/modules/access/access-errors.js.map +1 -0
  38. package/dist/modules/access/access-mutation.service.d.ts +176 -0
  39. package/dist/modules/access/access-mutation.service.d.ts.map +1 -0
  40. package/dist/modules/access/access-mutation.service.js +307 -0
  41. package/dist/modules/access/access-mutation.service.js.map +1 -0
  42. package/dist/modules/access/access-splice.d.ts +98 -0
  43. package/dist/modules/access/access-splice.d.ts.map +1 -0
  44. package/dist/modules/access/access-splice.js +346 -0
  45. package/dist/modules/access/access-splice.js.map +1 -0
  46. package/dist/modules/access/access.routes.d.ts +10 -0
  47. package/dist/modules/access/access.routes.d.ts.map +1 -0
  48. package/dist/modules/access/access.routes.js +757 -0
  49. package/dist/modules/access/access.routes.js.map +1 -0
  50. package/dist/modules/access/creator-access.d.ts +113 -0
  51. package/dist/modules/access/creator-access.d.ts.map +1 -0
  52. package/dist/modules/access/creator-access.js +212 -0
  53. package/dist/modules/access/creator-access.js.map +1 -0
  54. package/dist/modules/access/kb-read-filter.d.ts +41 -0
  55. package/dist/modules/access/kb-read-filter.d.ts.map +1 -0
  56. package/dist/modules/access/kb-read-filter.js +60 -0
  57. package/dist/modules/access/kb-read-filter.js.map +1 -0
  58. package/dist/modules/access/roles-admin.service.d.ts +240 -0
  59. package/dist/modules/access/roles-admin.service.d.ts.map +1 -0
  60. package/dist/modules/access/roles-admin.service.js +630 -0
  61. package/dist/modules/access/roles-admin.service.js.map +1 -0
  62. package/dist/modules/access/roles-edit.d.ts +91 -0
  63. package/dist/modules/access/roles-edit.d.ts.map +1 -0
  64. package/dist/modules/access/roles-edit.js +232 -0
  65. package/dist/modules/access/roles-edit.js.map +1 -0
  66. package/dist/modules/access/roles-yaml-guard.d.ts +56 -0
  67. package/dist/modules/access/roles-yaml-guard.d.ts.map +1 -0
  68. package/dist/modules/access/roles-yaml-guard.js +79 -0
  69. package/dist/modules/access/roles-yaml-guard.js.map +1 -0
  70. package/dist/modules/admin/admin-access.routes.d.ts +18 -0
  71. package/dist/modules/admin/admin-access.routes.d.ts.map +1 -0
  72. package/dist/modules/admin/admin-access.routes.js +26 -0
  73. package/dist/modules/admin/admin-access.routes.js.map +1 -0
  74. package/dist/modules/admin/admin-access.service.d.ts +24 -0
  75. package/dist/modules/admin/admin-access.service.d.ts.map +1 -0
  76. package/dist/modules/admin/admin-access.service.js +41 -0
  77. package/dist/modules/admin/admin-access.service.js.map +1 -0
  78. package/dist/modules/admin/admin.interface.d.ts +11 -0
  79. package/dist/modules/admin/admin.interface.d.ts.map +1 -0
  80. package/dist/modules/admin/admin.interface.js +2 -0
  81. package/dist/modules/admin/admin.interface.js.map +1 -0
  82. package/dist/modules/auth/account-erasure.service.d.ts +80 -0
  83. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -0
  84. package/dist/modules/auth/account-erasure.service.js +91 -0
  85. package/dist/modules/auth/account-erasure.service.js.map +1 -0
  86. package/dist/modules/auth/auth.middleware.d.ts +23 -0
  87. package/dist/modules/auth/auth.middleware.d.ts.map +1 -0
  88. package/dist/modules/auth/auth.middleware.js +68 -0
  89. package/dist/modules/auth/auth.middleware.js.map +1 -0
  90. package/dist/modules/auth/auth.routes.d.ts +25 -0
  91. package/dist/modules/auth/auth.routes.d.ts.map +1 -0
  92. package/dist/modules/auth/auth.routes.js +72 -0
  93. package/dist/modules/auth/auth.routes.js.map +1 -0
  94. package/dist/modules/auth/auth.service.d.ts +76 -0
  95. package/dist/modules/auth/auth.service.d.ts.map +1 -0
  96. package/dist/modules/auth/auth.service.js +168 -0
  97. package/dist/modules/auth/auth.service.js.map +1 -0
  98. package/dist/modules/code-mode/code-mode-names.d.ts +16 -0
  99. package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -0
  100. package/dist/modules/code-mode/code-mode-names.js +30 -0
  101. package/dist/modules/code-mode/code-mode-names.js.map +1 -0
  102. package/dist/modules/code-mode/code-mode.tool.d.ts +13 -0
  103. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -0
  104. package/dist/modules/code-mode/code-mode.tool.js +112 -0
  105. package/dist/modules/code-mode/code-mode.tool.js.map +1 -0
  106. package/dist/modules/code-mode/index.d.ts +3 -0
  107. package/dist/modules/code-mode/index.d.ts.map +1 -0
  108. package/dist/modules/code-mode/index.js +3 -0
  109. package/dist/modules/code-mode/index.js.map +1 -0
  110. package/dist/modules/database/connection.d.ts +6 -0
  111. package/dist/modules/database/connection.d.ts.map +1 -0
  112. package/dist/modules/database/connection.js +12 -0
  113. package/dist/modules/database/connection.js.map +1 -0
  114. package/dist/modules/database/core-schema.d.ts +2273 -0
  115. package/dist/modules/database/core-schema.d.ts.map +1 -0
  116. package/dist/modules/database/core-schema.js +413 -0
  117. package/dist/modules/database/core-schema.js.map +1 -0
  118. package/dist/modules/database/migrate.d.ts +10 -0
  119. package/dist/modules/database/migrate.d.ts.map +1 -0
  120. package/dist/modules/database/migrate.js +40 -0
  121. package/dist/modules/database/migrate.js.map +1 -0
  122. package/dist/modules/database/schema.d.ts +12 -0
  123. package/dist/modules/database/schema.d.ts.map +1 -0
  124. package/dist/modules/database/schema.js +12 -0
  125. package/dist/modules/database/schema.js.map +1 -0
  126. package/dist/modules/diff/diff-paths.d.ts +2 -0
  127. package/dist/modules/diff/diff-paths.d.ts.map +1 -0
  128. package/dist/modules/diff/diff-paths.js +9 -0
  129. package/dist/modules/diff/diff-paths.js.map +1 -0
  130. package/dist/modules/diff/diff.config.d.ts +8 -0
  131. package/dist/modules/diff/diff.config.d.ts.map +1 -0
  132. package/dist/modules/diff/diff.config.js +13 -0
  133. package/dist/modules/diff/diff.config.js.map +1 -0
  134. package/dist/modules/diff/diff.interface.d.ts +73 -0
  135. package/dist/modules/diff/diff.interface.d.ts.map +1 -0
  136. package/dist/modules/diff/diff.interface.js +2 -0
  137. package/dist/modules/diff/diff.interface.js.map +1 -0
  138. package/dist/modules/diff/diff.routes.d.ts +35 -0
  139. package/dist/modules/diff/diff.routes.d.ts.map +1 -0
  140. package/dist/modules/diff/diff.routes.js +228 -0
  141. package/dist/modules/diff/diff.routes.js.map +1 -0
  142. package/dist/modules/diff/diff.service.d.ts +51 -0
  143. package/dist/modules/diff/diff.service.d.ts.map +1 -0
  144. package/dist/modules/diff/diff.service.js +489 -0
  145. package/dist/modules/diff/diff.service.js.map +1 -0
  146. package/dist/modules/diff/line-diff.d.ts +16 -0
  147. package/dist/modules/diff/line-diff.d.ts.map +1 -0
  148. package/dist/modules/diff/line-diff.js +67 -0
  149. package/dist/modules/diff/line-diff.js.map +1 -0
  150. package/dist/modules/mcp/mcp-auth.middleware.d.ts +27 -0
  151. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -0
  152. package/dist/modules/mcp/mcp-auth.middleware.js +116 -0
  153. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -0
  154. package/dist/modules/mcp/mcp-session-store.d.ts +74 -0
  155. package/dist/modules/mcp/mcp-session-store.d.ts.map +1 -0
  156. package/dist/modules/mcp/mcp-session-store.js +131 -0
  157. package/dist/modules/mcp/mcp-session-store.js.map +1 -0
  158. package/dist/modules/mcp/mcp.routes.d.ts +24 -0
  159. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -0
  160. package/dist/modules/mcp/mcp.routes.js +229 -0
  161. package/dist/modules/mcp/mcp.routes.js.map +1 -0
  162. package/dist/modules/mcp/mcp.service.d.ts +198 -0
  163. package/dist/modules/mcp/mcp.service.d.ts.map +1 -0
  164. package/dist/modules/mcp/mcp.service.js +840 -0
  165. package/dist/modules/mcp/mcp.service.js.map +1 -0
  166. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +76 -0
  167. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -0
  168. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +291 -0
  169. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -0
  170. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts +18 -0
  171. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -0
  172. package/dist/modules/mcp/oauth/oauth-consent.routes.js +52 -0
  173. package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -0
  174. package/dist/modules/mcp/oauth/oauth-state.d.ts +32 -0
  175. package/dist/modules/mcp/oauth/oauth-state.d.ts.map +1 -0
  176. package/dist/modules/mcp/oauth/oauth-state.js +34 -0
  177. package/dist/modules/mcp/oauth/oauth-state.js.map +1 -0
  178. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +92 -0
  179. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -0
  180. package/dist/modules/secrets-vault/db-secrets-vault.service.js +728 -0
  181. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -0
  182. package/dist/modules/secrets-vault/index.d.ts +7 -0
  183. package/dist/modules/secrets-vault/index.d.ts.map +1 -0
  184. package/dist/modules/secrets-vault/index.js +6 -0
  185. package/dist/modules/secrets-vault/index.js.map +1 -0
  186. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +63 -0
  187. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -0
  188. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +269 -0
  189. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -0
  190. package/dist/modules/secrets-vault/secrets-variable-loader.d.ts +46 -0
  191. package/dist/modules/secrets-vault/secrets-variable-loader.d.ts.map +1 -0
  192. package/dist/modules/secrets-vault/secrets-variable-loader.js +87 -0
  193. package/dist/modules/secrets-vault/secrets-variable-loader.js.map +1 -0
  194. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +210 -0
  195. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -0
  196. package/dist/modules/secrets-vault/secrets-vault.contract.js +90 -0
  197. package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -0
  198. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +30 -0
  199. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -0
  200. package/dist/modules/secrets-vault/secrets-vault.routes.js +490 -0
  201. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -0
  202. package/dist/modules/skills/index.d.ts +5 -0
  203. package/dist/modules/skills/index.d.ts.map +1 -0
  204. package/dist/modules/skills/index.js +4 -0
  205. package/dist/modules/skills/index.js.map +1 -0
  206. package/dist/modules/skills/skills.contract.d.ts +58 -0
  207. package/dist/modules/skills/skills.contract.d.ts.map +1 -0
  208. package/dist/modules/skills/skills.contract.js +10 -0
  209. package/dist/modules/skills/skills.contract.js.map +1 -0
  210. package/dist/modules/skills/skills.routes.d.ts +15 -0
  211. package/dist/modules/skills/skills.routes.d.ts.map +1 -0
  212. package/dist/modules/skills/skills.routes.js +34 -0
  213. package/dist/modules/skills/skills.routes.js.map +1 -0
  214. package/dist/modules/skills/skills.service.d.ts +21 -0
  215. package/dist/modules/skills/skills.service.d.ts.map +1 -0
  216. package/dist/modules/skills/skills.service.js +202 -0
  217. package/dist/modules/skills/skills.service.js.map +1 -0
  218. package/dist/modules/skills/skills.tools.d.ts +15 -0
  219. package/dist/modules/skills/skills.tools.d.ts.map +1 -0
  220. package/dist/modules/skills/skills.tools.js +96 -0
  221. package/dist/modules/skills/skills.tools.js.map +1 -0
  222. package/dist/modules/tool-auth/external-api-key.errors.d.ts +14 -0
  223. package/dist/modules/tool-auth/external-api-key.errors.d.ts.map +1 -0
  224. package/dist/modules/tool-auth/external-api-key.errors.js +23 -0
  225. package/dist/modules/tool-auth/external-api-key.errors.js.map +1 -0
  226. package/dist/modules/tool-auth/external-api-key.interface.d.ts +90 -0
  227. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -0
  228. package/dist/modules/tool-auth/external-api-key.interface.js +2 -0
  229. package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -0
  230. package/dist/modules/tool-auth/external-api-key.service.d.ts +25 -0
  231. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -0
  232. package/dist/modules/tool-auth/external-api-key.service.js +164 -0
  233. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -0
  234. package/dist/modules/tool-auth/internal-token.service.d.ts +62 -0
  235. package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -0
  236. package/dist/modules/tool-auth/internal-token.service.js +85 -0
  237. package/dist/modules/tool-auth/internal-token.service.js.map +1 -0
  238. package/dist/modules/tool-auth/llm-usage-meter.d.ts +16 -0
  239. package/dist/modules/tool-auth/llm-usage-meter.d.ts.map +1 -0
  240. package/dist/modules/tool-auth/llm-usage-meter.js +6 -0
  241. package/dist/modules/tool-auth/llm-usage-meter.js.map +1 -0
  242. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +101 -0
  243. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -0
  244. package/dist/modules/tool-auth/tool-auth.middleware.js +166 -0
  245. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -0
  246. package/dist/modules/tool-helpers/index.d.ts +19 -0
  247. package/dist/modules/tool-helpers/index.d.ts.map +1 -0
  248. package/dist/modules/tool-helpers/index.js +18 -0
  249. package/dist/modules/tool-helpers/index.js.map +1 -0
  250. package/dist/modules/tool-helpers/tool-context.d.ts +35 -0
  251. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -0
  252. package/dist/modules/tool-helpers/tool-context.js +66 -0
  253. package/dist/modules/tool-helpers/tool-context.js.map +1 -0
  254. package/dist/modules/tool-helpers/tool-def.d.ts +33 -0
  255. package/dist/modules/tool-helpers/tool-def.d.ts.map +1 -0
  256. package/dist/modules/tool-helpers/tool-def.js +57 -0
  257. package/dist/modules/tool-helpers/tool-def.js.map +1 -0
  258. package/dist/modules/tool-helpers/tool-handler.d.ts +19 -0
  259. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -0
  260. package/dist/modules/tool-helpers/tool-handler.js +84 -0
  261. package/dist/modules/tool-helpers/tool-handler.js.map +1 -0
  262. package/dist/modules/tool-helpers/tool.contract.d.ts +64 -0
  263. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -0
  264. package/dist/modules/tool-helpers/tool.contract.js +24 -0
  265. package/dist/modules/tool-helpers/tool.contract.js.map +1 -0
  266. package/dist/modules/tool-helpers/validate-token.d.ts +31 -0
  267. package/dist/modules/tool-helpers/validate-token.d.ts.map +1 -0
  268. package/dist/modules/tool-helpers/validate-token.js +20 -0
  269. package/dist/modules/tool-helpers/validate-token.js.map +1 -0
  270. package/dist/modules/tool-manuals/index.d.ts +5 -0
  271. package/dist/modules/tool-manuals/index.d.ts.map +1 -0
  272. package/dist/modules/tool-manuals/index.js +5 -0
  273. package/dist/modules/tool-manuals/index.js.map +1 -0
  274. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +182 -0
  275. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -0
  276. package/dist/modules/tool-manuals/tool-manuals.contract.js +19 -0
  277. package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -0
  278. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +24 -0
  279. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -0
  280. package/dist/modules/tool-manuals/tool-manuals.routes.js +102 -0
  281. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -0
  282. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +108 -0
  283. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -0
  284. package/dist/modules/tool-manuals/tool-manuals.service.js +710 -0
  285. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -0
  286. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +35 -0
  287. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -0
  288. package/dist/modules/tool-manuals/tool-manuals.tools.js +157 -0
  289. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -0
  290. package/dist/modules/tool-registry/manual.routes.d.ts +6 -0
  291. package/dist/modules/tool-registry/manual.routes.d.ts.map +1 -0
  292. package/dist/modules/tool-registry/manual.routes.js +76 -0
  293. package/dist/modules/tool-registry/manual.routes.js.map +1 -0
  294. package/dist/modules/tool-registry/tool-registry.d.ts +23 -0
  295. package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -0
  296. package/dist/modules/tool-registry/tool-registry.js +46 -0
  297. package/dist/modules/tool-registry/tool-registry.js.map +1 -0
  298. package/dist/modules/tool-registry/tool.contract.d.ts +48 -0
  299. package/dist/modules/tool-registry/tool.contract.d.ts.map +1 -0
  300. package/dist/modules/tool-registry/tool.contract.js +2 -0
  301. package/dist/modules/tool-registry/tool.contract.js.map +1 -0
  302. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +14 -0
  303. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -0
  304. package/dist/modules/workflow/agent-tools/workflow.tools.js +437 -0
  305. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -0
  306. package/dist/modules/workflow/event-bus.d.ts +107 -0
  307. package/dist/modules/workflow/event-bus.d.ts.map +1 -0
  308. package/dist/modules/workflow/event-bus.js +224 -0
  309. package/dist/modules/workflow/event-bus.js.map +1 -0
  310. package/dist/modules/workflow/events.routes.d.ts +80 -0
  311. package/dist/modules/workflow/events.routes.d.ts.map +1 -0
  312. package/dist/modules/workflow/events.routes.js +216 -0
  313. package/dist/modules/workflow/events.routes.js.map +1 -0
  314. package/dist/modules/workflow/file-change-notifier.d.ts +38 -0
  315. package/dist/modules/workflow/file-change-notifier.d.ts.map +1 -0
  316. package/dist/modules/workflow/file-change-notifier.js +22 -0
  317. package/dist/modules/workflow/file-change-notifier.js.map +1 -0
  318. package/dist/modules/workflow/file-lock.service.d.ts +57 -0
  319. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -0
  320. package/dist/modules/workflow/file-lock.service.js +202 -0
  321. package/dist/modules/workflow/file-lock.service.js.map +1 -0
  322. package/dist/modules/workflow/git/branch-name.d.ts +10 -0
  323. package/dist/modules/workflow/git/branch-name.d.ts.map +1 -0
  324. package/dist/modules/workflow/git/branch-name.js +76 -0
  325. package/dist/modules/workflow/git/branch-name.js.map +1 -0
  326. package/dist/modules/workflow/git/clone-config.d.ts +61 -0
  327. package/dist/modules/workflow/git/clone-config.d.ts.map +1 -0
  328. package/dist/modules/workflow/git/clone-config.js +69 -0
  329. package/dist/modules/workflow/git/clone-config.js.map +1 -0
  330. package/dist/modules/workflow/git/git-version.d.ts +34 -0
  331. package/dist/modules/workflow/git/git-version.d.ts.map +1 -0
  332. package/dist/modules/workflow/git/git-version.js +63 -0
  333. package/dist/modules/workflow/git/git-version.js.map +1 -0
  334. package/dist/modules/workflow/git/git.service.d.ts +427 -0
  335. package/dist/modules/workflow/git/git.service.d.ts.map +1 -0
  336. package/dist/modules/workflow/git/git.service.js +1987 -0
  337. package/dist/modules/workflow/git/git.service.js.map +1 -0
  338. package/dist/modules/workflow/git/mutex.d.ts +11 -0
  339. package/dist/modules/workflow/git/mutex.d.ts.map +1 -0
  340. package/dist/modules/workflow/git/mutex.js +23 -0
  341. package/dist/modules/workflow/git/mutex.js.map +1 -0
  342. package/dist/modules/workflow/git/pull-request.service.d.ts +124 -0
  343. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -0
  344. package/dist/modules/workflow/git/pull-request.service.js +328 -0
  345. package/dist/modules/workflow/git/pull-request.service.js.map +1 -0
  346. package/dist/modules/workflow/locking-filesystem.d.ts +133 -0
  347. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -0
  348. package/dist/modules/workflow/locking-filesystem.js +400 -0
  349. package/dist/modules/workflow/locking-filesystem.js.map +1 -0
  350. package/dist/modules/workflow/pending-commits.service.d.ts +221 -0
  351. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -0
  352. package/dist/modules/workflow/pending-commits.service.js +364 -0
  353. package/dist/modules/workflow/pending-commits.service.js.map +1 -0
  354. package/dist/modules/workflow/pending-commits.worker.d.ts +148 -0
  355. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -0
  356. package/dist/modules/workflow/pending-commits.worker.js +245 -0
  357. package/dist/modules/workflow/pending-commits.worker.js.map +1 -0
  358. package/dist/modules/workflow/read-only-filesystem.d.ts +24 -0
  359. package/dist/modules/workflow/read-only-filesystem.d.ts.map +1 -0
  360. package/dist/modules/workflow/read-only-filesystem.js +39 -0
  361. package/dist/modules/workflow/read-only-filesystem.js.map +1 -0
  362. package/dist/modules/workflow/recovery-bot.d.ts +31 -0
  363. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -0
  364. package/dist/modules/workflow/recovery-bot.js +64 -0
  365. package/dist/modules/workflow/recovery-bot.js.map +1 -0
  366. package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts +137 -0
  367. package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts.map +1 -0
  368. package/dist/modules/workflow/review-workflow/review-workflow.interface.js +2 -0
  369. package/dist/modules/workflow/review-workflow/review-workflow.interface.js.map +1 -0
  370. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +26 -0
  371. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -0
  372. package/dist/modules/workflow/review-workflow/review-workflow.service.js +636 -0
  373. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -0
  374. package/dist/modules/workflow/sanitize-error.d.ts +29 -0
  375. package/dist/modules/workflow/sanitize-error.d.ts.map +1 -0
  376. package/dist/modules/workflow/sanitize-error.js +60 -0
  377. package/dist/modules/workflow/sanitize-error.js.map +1 -0
  378. package/dist/modules/workflow/session-ontology.policy.d.ts +52 -0
  379. package/dist/modules/workflow/session-ontology.policy.d.ts.map +1 -0
  380. package/dist/modules/workflow/session-ontology.policy.js +62 -0
  381. package/dist/modules/workflow/session-ontology.policy.js.map +1 -0
  382. package/dist/modules/workflow/session-ontology.service.d.ts +100 -0
  383. package/dist/modules/workflow/session-ontology.service.d.ts.map +1 -0
  384. package/dist/modules/workflow/session-ontology.service.js +143 -0
  385. package/dist/modules/workflow/session-ontology.service.js.map +1 -0
  386. package/dist/modules/workflow/workflow-hooks.d.ts +87 -0
  387. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -0
  388. package/dist/modules/workflow/workflow-hooks.js +35 -0
  389. package/dist/modules/workflow/workflow-hooks.js.map +1 -0
  390. package/dist/modules/workflow/workflow.errors.d.ts +171 -0
  391. package/dist/modules/workflow/workflow.errors.d.ts.map +1 -0
  392. package/dist/modules/workflow/workflow.errors.js +260 -0
  393. package/dist/modules/workflow/workflow.errors.js.map +1 -0
  394. package/dist/modules/workflow/workflow.routes.d.ts +26 -0
  395. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -0
  396. package/dist/modules/workflow/workflow.routes.js +804 -0
  397. package/dist/modules/workflow/workflow.routes.js.map +1 -0
  398. package/dist/modules/workflow/workflow.service.d.ts +385 -0
  399. package/dist/modules/workflow/workflow.service.d.ts.map +1 -0
  400. package/dist/modules/workflow/workflow.service.js +1155 -0
  401. package/dist/modules/workflow/workflow.service.js.map +1 -0
  402. package/dist/modules/workspace/bevel-ignore.d.ts +25 -0
  403. package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -0
  404. package/dist/modules/workspace/bevel-ignore.js +57 -0
  405. package/dist/modules/workspace/bevel-ignore.js.map +1 -0
  406. package/dist/modules/workspace/kb-seed.interface.d.ts +36 -0
  407. package/dist/modules/workspace/kb-seed.interface.d.ts.map +1 -0
  408. package/dist/modules/workspace/kb-seed.interface.js +2 -0
  409. package/dist/modules/workspace/kb-seed.interface.js.map +1 -0
  410. package/dist/modules/workspace/kb-seed.service.d.ts +70 -0
  411. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -0
  412. package/dist/modules/workspace/kb-seed.service.js +325 -0
  413. package/dist/modules/workspace/kb-seed.service.js.map +1 -0
  414. package/dist/modules/workspace/routine-write-policy.d.ts +49 -0
  415. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -0
  416. package/dist/modules/workspace/routine-write-policy.js +46 -0
  417. package/dist/modules/workspace/routine-write-policy.js.map +1 -0
  418. package/dist/modules/workspace/session-ontology.gate.d.ts +112 -0
  419. package/dist/modules/workspace/session-ontology.gate.d.ts.map +1 -0
  420. package/dist/modules/workspace/session-ontology.gate.js +161 -0
  421. package/dist/modules/workspace/session-ontology.gate.js.map +1 -0
  422. package/dist/modules/workspace/session-sink.d.ts +25 -0
  423. package/dist/modules/workspace/session-sink.d.ts.map +1 -0
  424. package/dist/modules/workspace/session-sink.js +8 -0
  425. package/dist/modules/workspace/session-sink.js.map +1 -0
  426. package/dist/modules/workspace/spill-store.d.ts +24 -0
  427. package/dist/modules/workspace/spill-store.d.ts.map +1 -0
  428. package/dist/modules/workspace/spill-store.js +87 -0
  429. package/dist/modules/workspace/spill-store.js.map +1 -0
  430. package/dist/modules/workspace/workspace.routes.d.ts +25 -0
  431. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -0
  432. package/dist/modules/workspace/workspace.routes.js +1046 -0
  433. package/dist/modules/workspace/workspace.routes.js.map +1 -0
  434. package/dist/modules/workspace/workspace.service.d.ts +339 -0
  435. package/dist/modules/workspace/workspace.service.d.ts.map +1 -0
  436. package/dist/modules/workspace/workspace.service.js +1221 -0
  437. package/dist/modules/workspace/workspace.service.js.map +1 -0
  438. package/dist/modules/workspace/workspace.tools.d.ts +19 -0
  439. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -0
  440. package/dist/modules/workspace/workspace.tools.js +767 -0
  441. package/dist/modules/workspace/workspace.tools.js.map +1 -0
  442. package/dist/shared/frontmatter-id.d.ts +35 -0
  443. package/dist/shared/frontmatter-id.d.ts.map +1 -0
  444. package/dist/shared/frontmatter-id.js +68 -0
  445. package/dist/shared/frontmatter-id.js.map +1 -0
  446. package/dist/shared/fs-walk.d.ts +9 -0
  447. package/dist/shared/fs-walk.d.ts.map +1 -0
  448. package/dist/shared/fs-walk.js +34 -0
  449. package/dist/shared/fs-walk.js.map +1 -0
  450. package/dist/shared/hash-email.d.ts +11 -0
  451. package/dist/shared/hash-email.d.ts.map +1 -0
  452. package/dist/shared/hash-email.js +14 -0
  453. package/dist/shared/hash-email.js.map +1 -0
  454. package/dist/shared/kb-layout.d.ts +38 -0
  455. package/dist/shared/kb-layout.d.ts.map +1 -0
  456. package/dist/shared/kb-layout.js +102 -0
  457. package/dist/shared/kb-layout.js.map +1 -0
  458. package/dist/shared/kb-layout.test.d.ts +2 -0
  459. package/dist/shared/kb-layout.test.d.ts.map +1 -0
  460. package/dist/shared/kb-layout.test.js +67 -0
  461. package/dist/shared/kb-layout.test.js.map +1 -0
  462. package/dist/shared/ssrf.d.ts +20 -0
  463. package/dist/shared/ssrf.d.ts.map +1 -0
  464. package/dist/shared/ssrf.js +89 -0
  465. package/dist/shared/ssrf.js.map +1 -0
  466. package/dist/shared/token-crypto.d.ts +16 -0
  467. package/dist/shared/token-crypto.d.ts.map +1 -0
  468. package/dist/shared/token-crypto.js +49 -0
  469. package/dist/shared/token-crypto.js.map +1 -0
  470. package/dist/shared/ttl-cache.d.ts +16 -0
  471. package/dist/shared/ttl-cache.d.ts.map +1 -0
  472. package/dist/shared/ttl-cache.js +27 -0
  473. package/dist/shared/ttl-cache.js.map +1 -0
  474. package/dist/shared/utcp-namespace.d.ts +46 -0
  475. package/dist/shared/utcp-namespace.d.ts.map +1 -0
  476. package/dist/shared/utcp-namespace.js +82 -0
  477. package/dist/shared/utcp-namespace.js.map +1 -0
  478. package/dist/version.d.ts +17 -0
  479. package/dist/version.d.ts.map +1 -0
  480. package/dist/version.js +19 -0
  481. package/dist/version.js.map +1 -0
  482. package/kb-template/.bevelignore +21 -0
  483. package/kb-template/Agents/.gitkeep +0 -0
  484. package/kb-template/Agents/README.md +38 -0
  485. package/kb-template/CLAUDE.md +419 -0
  486. package/kb-template/Data/.gitkeep +0 -0
  487. package/kb-template/Data/README.md +39 -0
  488. package/kb-template/KnowledgeBase/.gitkeep +0 -0
  489. package/kb-template/KnowledgeBase/README.md +37 -0
  490. package/kb-template/Pipelines/.gitkeep +0 -0
  491. package/kb-template/Pipelines/README.md +38 -0
  492. package/kb-template/Skills/.gitkeep +0 -0
  493. package/kb-template/Skills/README.md +32 -0
  494. package/kb-template/Tools/.gitkeep +0 -0
  495. package/kb-template/Tools/README.md +42 -0
  496. package/kb-template/access.md +36 -0
  497. package/migrations/0000_core_init.sql +243 -0
  498. package/migrations/meta/0000_snapshot.json +1480 -0
  499. package/migrations/meta/_journal.json +13 -0
  500. package/package.json +80 -0
  501. package/src/assets.ts +25 -0
  502. package/src/core/core-ports.ts +91 -0
  503. package/src/core/create-core-server.ts +338 -0
  504. package/src/core/create-core-services.ts +501 -0
  505. package/src/core-config.ts +257 -0
  506. package/src/index.ts +69 -0
  507. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -0
  508. package/src/modules/access/__tests__/access-control.read-batch.test.ts +132 -0
  509. package/src/modules/access/__tests__/access-control.service.test.ts +1011 -0
  510. package/src/modules/access/__tests__/access-mutation.service.test.ts +439 -0
  511. package/src/modules/access/__tests__/access-splice.test.ts +247 -0
  512. package/src/modules/access/__tests__/access.routes.revoke.test.ts +407 -0
  513. package/src/modules/access/__tests__/creator-access.test.ts +217 -0
  514. package/src/modules/access/__tests__/grant-sources.test.ts +204 -0
  515. package/src/modules/access/__tests__/kb-read-filter.test.ts +70 -0
  516. package/src/modules/access/__tests__/roles-admin.service.test.ts +469 -0
  517. package/src/modules/access/__tests__/roles-edit.test.ts +138 -0
  518. package/src/modules/access/__tests__/roles-yaml-guard.test.ts +70 -0
  519. package/src/modules/access/__tests__/roles.routes.test.ts +234 -0
  520. package/src/modules/access/access-control.interface.ts +383 -0
  521. package/src/modules/access/access-control.service.ts +1794 -0
  522. package/src/modules/access/access-errors.ts +60 -0
  523. package/src/modules/access/access-mutation.service.ts +369 -0
  524. package/src/modules/access/access-splice.ts +413 -0
  525. package/src/modules/access/access.routes.ts +894 -0
  526. package/src/modules/access/creator-access.ts +270 -0
  527. package/src/modules/access/kb-read-filter.ts +76 -0
  528. package/src/modules/access/roles-admin.service.ts +741 -0
  529. package/src/modules/access/roles-edit.ts +248 -0
  530. package/src/modules/access/roles-yaml-guard.ts +89 -0
  531. package/src/modules/admin/admin-access.routes.ts +29 -0
  532. package/src/modules/admin/admin-access.service.ts +38 -0
  533. package/src/modules/admin/admin.interface.ts +10 -0
  534. package/src/modules/auth/account-erasure.service.ts +183 -0
  535. package/src/modules/auth/auth.middleware.ts +78 -0
  536. package/src/modules/auth/auth.routes.ts +97 -0
  537. package/src/modules/auth/auth.service.ts +199 -0
  538. package/src/modules/code-mode/code-mode-names.ts +38 -0
  539. package/src/modules/code-mode/code-mode.tool.ts +117 -0
  540. package/src/modules/code-mode/index.ts +6 -0
  541. package/src/modules/database/connection.ts +15 -0
  542. package/src/modules/database/core-schema.ts +431 -0
  543. package/src/modules/database/migrate.ts +43 -0
  544. package/src/modules/database/schema.ts +11 -0
  545. package/src/modules/diff/__tests__/diff.routes.read-gate.test.ts +87 -0
  546. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -0
  547. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +184 -0
  548. package/src/modules/diff/__tests__/diff.service.test.ts +202 -0
  549. package/src/modules/diff/diff-paths.ts +9 -0
  550. package/src/modules/diff/diff.config.ts +12 -0
  551. package/src/modules/diff/diff.interface.ts +78 -0
  552. package/src/modules/diff/diff.routes.ts +277 -0
  553. package/src/modules/diff/diff.service.ts +508 -0
  554. package/src/modules/diff/line-diff.ts +79 -0
  555. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +287 -0
  556. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +249 -0
  557. package/src/modules/mcp/__tests__/mcp-proxy-helpers.test.ts +253 -0
  558. package/src/modules/mcp/__tests__/mcp-session-store.test.ts +151 -0
  559. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +150 -0
  560. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +92 -0
  561. package/src/modules/mcp/__tests__/mcp.service.test.ts +414 -0
  562. package/src/modules/mcp/mcp-auth.middleware.ts +126 -0
  563. package/src/modules/mcp/mcp-session-store.ts +162 -0
  564. package/src/modules/mcp/mcp.routes.ts +263 -0
  565. package/src/modules/mcp/mcp.service.ts +973 -0
  566. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +401 -0
  567. package/src/modules/mcp/oauth/oauth-consent.routes.ts +60 -0
  568. package/src/modules/mcp/oauth/oauth-state.ts +61 -0
  569. package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +277 -0
  570. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +206 -0
  571. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +178 -0
  572. package/src/modules/secrets-vault/__tests__/scopes-covered.test.ts +101 -0
  573. package/src/modules/secrets-vault/__tests__/secrets-variable-loader.test.ts +54 -0
  574. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -0
  575. package/src/modules/secrets-vault/db-secrets-vault.service.ts +792 -0
  576. package/src/modules/secrets-vault/index.ts +25 -0
  577. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +289 -0
  578. package/src/modules/secrets-vault/secrets-variable-loader.ts +104 -0
  579. package/src/modules/secrets-vault/secrets-vault.contract.ts +270 -0
  580. package/src/modules/secrets-vault/secrets-vault.routes.ts +526 -0
  581. package/src/modules/skills/__tests__/skills.service.test.ts +176 -0
  582. package/src/modules/skills/index.ts +10 -0
  583. package/src/modules/skills/skills.contract.ts +54 -0
  584. package/src/modules/skills/skills.routes.ts +38 -0
  585. package/src/modules/skills/skills.service.ts +245 -0
  586. package/src/modules/skills/skills.tools.ts +121 -0
  587. package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +358 -0
  588. package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +79 -0
  589. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +88 -0
  590. package/src/modules/tool-auth/external-api-key.errors.ts +25 -0
  591. package/src/modules/tool-auth/external-api-key.interface.ts +99 -0
  592. package/src/modules/tool-auth/external-api-key.service.ts +209 -0
  593. package/src/modules/tool-auth/internal-token.service.ts +124 -0
  594. package/src/modules/tool-auth/llm-usage-meter.ts +19 -0
  595. package/src/modules/tool-auth/tool-auth.middleware.ts +220 -0
  596. package/src/modules/tool-helpers/__tests__/phase0-loopback-spike.test.ts +129 -0
  597. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +95 -0
  598. package/src/modules/tool-helpers/__tests__/tools-surface.test.ts +146 -0
  599. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +75 -0
  600. package/src/modules/tool-helpers/index.ts +18 -0
  601. package/src/modules/tool-helpers/tool-context.ts +98 -0
  602. package/src/modules/tool-helpers/tool-def.ts +73 -0
  603. package/src/modules/tool-helpers/tool-handler.ts +91 -0
  604. package/src/modules/tool-helpers/tool.contract.ts +85 -0
  605. package/src/modules/tool-helpers/validate-token.ts +50 -0
  606. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +200 -0
  607. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +499 -0
  608. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -0
  609. package/src/modules/tool-manuals/index.ts +12 -0
  610. package/src/modules/tool-manuals/tool-manuals.contract.ts +183 -0
  611. package/src/modules/tool-manuals/tool-manuals.routes.ts +110 -0
  612. package/src/modules/tool-manuals/tool-manuals.service.ts +781 -0
  613. package/src/modules/tool-manuals/tool-manuals.tools.ts +202 -0
  614. package/src/modules/tool-registry/__tests__/tool-registry.test.ts +64 -0
  615. package/src/modules/tool-registry/manual.routes.ts +87 -0
  616. package/src/modules/tool-registry/tool-registry.ts +55 -0
  617. package/src/modules/tool-registry/tool.contract.ts +52 -0
  618. package/src/modules/workflow/__tests__/event-bus.test.ts +322 -0
  619. package/src/modules/workflow/__tests__/file-change-notifier.test.ts +51 -0
  620. package/src/modules/workflow/__tests__/format-affected-owners.test.ts +67 -0
  621. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +445 -0
  622. package/src/modules/workflow/__tests__/pending-commits.canonical.test.ts +36 -0
  623. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +243 -0
  624. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +308 -0
  625. package/src/modules/workflow/__tests__/sanitize-error.test.ts +67 -0
  626. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +62 -0
  627. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +200 -0
  628. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +232 -0
  629. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +380 -0
  630. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +340 -0
  631. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +192 -0
  632. package/src/modules/workflow/agent-tools/workflow.tools.ts +502 -0
  633. package/src/modules/workflow/event-bus.ts +262 -0
  634. package/src/modules/workflow/events.routes.ts +266 -0
  635. package/src/modules/workflow/file-change-notifier.ts +54 -0
  636. package/src/modules/workflow/file-lock.service.ts +269 -0
  637. package/src/modules/workflow/git/__tests__/branch-name.test.ts +86 -0
  638. package/src/modules/workflow/git/__tests__/branchAuthor.test.ts +80 -0
  639. package/src/modules/workflow/git/__tests__/clone-config.test.ts +15 -0
  640. package/src/modules/workflow/git/__tests__/git-test-helpers.ts +47 -0
  641. package/src/modules/workflow/git/__tests__/git-version.failure.test.ts +24 -0
  642. package/src/modules/workflow/git/__tests__/git-version.test.ts +49 -0
  643. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +389 -0
  644. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
  645. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +105 -0
  646. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +211 -0
  647. package/src/modules/workflow/git/__tests__/git.service.createBranch.test.ts +154 -0
  648. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +320 -0
  649. package/src/modules/workflow/git/__tests__/git.service.diffFileAtCommit.test.ts +202 -0
  650. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +453 -0
  651. package/src/modules/workflow/git/__tests__/git.service.list-branches.test.ts +198 -0
  652. package/src/modules/workflow/git/__tests__/git.service.listTrackedFiles.test.ts +125 -0
  653. package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +219 -0
  654. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +145 -0
  655. package/src/modules/workflow/git/__tests__/git.service.pendingChanges.test.ts +240 -0
  656. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +272 -0
  657. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +232 -0
  658. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +101 -0
  659. package/src/modules/workflow/git/__tests__/git.service.status.test.ts +127 -0
  660. package/src/modules/workflow/git/__tests__/mutex.test.ts +78 -0
  661. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +225 -0
  662. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +101 -0
  663. package/src/modules/workflow/git/branch-name.ts +70 -0
  664. package/src/modules/workflow/git/clone-config.ts +71 -0
  665. package/src/modules/workflow/git/git-version.ts +70 -0
  666. package/src/modules/workflow/git/git.service.ts +2248 -0
  667. package/src/modules/workflow/git/mutex.ts +22 -0
  668. package/src/modules/workflow/git/pull-request.service.ts +447 -0
  669. package/src/modules/workflow/locking-filesystem.ts +489 -0
  670. package/src/modules/workflow/pending-commits.service.ts +444 -0
  671. package/src/modules/workflow/pending-commits.worker.ts +361 -0
  672. package/src/modules/workflow/read-only-filesystem.ts +54 -0
  673. package/src/modules/workflow/recovery-bot.ts +73 -0
  674. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +560 -0
  675. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +176 -0
  676. package/src/modules/workflow/review-workflow/__tests__/merge-gate.test.ts +258 -0
  677. package/src/modules/workflow/review-workflow/review-workflow.interface.ts +199 -0
  678. package/src/modules/workflow/review-workflow/review-workflow.service.ts +806 -0
  679. package/src/modules/workflow/sanitize-error.ts +62 -0
  680. package/src/modules/workflow/session-ontology.policy.ts +70 -0
  681. package/src/modules/workflow/session-ontology.service.ts +178 -0
  682. package/src/modules/workflow/workflow-hooks.ts +101 -0
  683. package/src/modules/workflow/workflow.errors.ts +285 -0
  684. package/src/modules/workflow/workflow.routes.ts +858 -0
  685. package/src/modules/workflow/workflow.service.ts +1555 -0
  686. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +213 -0
  687. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +68 -0
  688. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -0
  689. package/src/modules/workspace/__tests__/spill-store.test.ts +43 -0
  690. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +233 -0
  691. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +176 -0
  692. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +303 -0
  693. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +276 -0
  694. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +120 -0
  695. package/src/modules/workspace/__tests__/workspace.service.test.ts +565 -0
  696. package/src/modules/workspace/__tests__/workspace.tools.test.ts +504 -0
  697. package/src/modules/workspace/bevel-ignore.ts +62 -0
  698. package/src/modules/workspace/kb-seed.interface.ts +36 -0
  699. package/src/modules/workspace/kb-seed.service.ts +361 -0
  700. package/src/modules/workspace/routine-write-policy.ts +79 -0
  701. package/src/modules/workspace/session-ontology.gate.ts +189 -0
  702. package/src/modules/workspace/session-sink.ts +25 -0
  703. package/src/modules/workspace/spill-store.ts +87 -0
  704. package/src/modules/workspace/workspace.routes.ts +1114 -0
  705. package/src/modules/workspace/workspace.service.ts +1343 -0
  706. package/src/modules/workspace/workspace.tools.ts +885 -0
  707. package/src/shared/__tests__/frontmatter-id.test.ts +88 -0
  708. package/src/shared/__tests__/fs-walk.test.ts +42 -0
  709. package/src/shared/__tests__/ssrf.test.ts +53 -0
  710. package/src/shared/__tests__/utcp-namespace.test.ts +19 -0
  711. package/src/shared/frontmatter-id.ts +72 -0
  712. package/src/shared/fs-walk.ts +30 -0
  713. package/src/shared/hash-email.ts +14 -0
  714. package/src/shared/kb-layout.test.ts +88 -0
  715. package/src/shared/kb-layout.ts +101 -0
  716. package/src/shared/ssrf.ts +78 -0
  717. package/src/shared/token-crypto.ts +54 -0
  718. package/src/shared/ttl-cache.ts +27 -0
  719. package/src/shared/utcp-namespace.ts +88 -0
  720. package/src/version.ts +19 -0
@@ -0,0 +1,1555 @@
1
+ /**
2
+ * Workflow facade — the only thing app callers (routes, agent tools, tests)
3
+ * should reach for. Today it delegates to the existing git / PR /
4
+ * review-workflow services; over PLAN steps 2–5 the implementations migrate
5
+ * here directly and the underlying services either disappear or move
6
+ * underneath this module.
7
+ *
8
+ * The facade does no caching, no validation, no policy of its own — it
9
+ * exists to:
10
+ * 1. Lock in the workflow vocabulary on the consumer side.
11
+ * 2. Concentrate every call into git/PR/review-workflow into one place
12
+ * so the agent prompt can stop knowing about `git` / `gh`.
13
+ *
14
+ * Methods backed by services that don't exist yet (locks, openChangeRequest,
15
+ * updateFromTarget) throw `NotImplementedWorkflowError`. The interface
16
+ * defines them so frontend / agent code can wire to the final surface while
17
+ * those backings land.
18
+ */
19
+
20
+ import path from 'node:path';
21
+ import { promises as fs } from 'node:fs';
22
+ import { execFile } from 'node:child_process';
23
+ import { promisify } from 'node:util';
24
+ import type {
25
+ AcquireLockResult,
26
+ AuthUser,
27
+ Branch,
28
+ BranchWorkspaceStatus,
29
+ CancelChangeRequestResult,
30
+ Change,
31
+ ChangeInput,
32
+ ChangeRequest,
33
+ ChangeRequestComment,
34
+ ChangeRequestDetail,
35
+ ChangeRequestState,
36
+ ChangedFile,
37
+ FileApproval,
38
+ FileLock,
39
+ IWorkflowService,
40
+ MergeChangeRequestOutcome,
41
+ OpenChangeRequestInput,
42
+ PostChangeRequestCommentInput,
43
+ } from '@bevel-software/platform-shared';
44
+ import { isProtectedBranch } from '@bevel-software/platform-shared';
45
+ import { and, eq } from 'drizzle-orm';
46
+ import type { Database } from '../database/connection.js';
47
+ import { changeRequests } from '../database/schema.js';
48
+ import type { GitService } from './git/git.service.js';
49
+ import type { PullRequestService } from './git/pull-request.service.js';
50
+ import type { IReviewWorkflowService } from './review-workflow/review-workflow.interface.js';
51
+ import type { WorkspaceService } from '../workspace/workspace.service.js';
52
+ import type { IAccessControl } from '../access/access-control.interface.js';
53
+ import { FileLockService } from './file-lock.service.js';
54
+ import { PendingCommitsService } from './pending-commits.service.js';
55
+ import type { WorkflowEventBus } from './event-bus.js';
56
+ import type { FileChangeNotifier } from './file-change-notifier.js';
57
+ import { WorkflowHooks } from './workflow-hooks.js';
58
+ import { hashEmail } from '../../shared/hash-email.js';
59
+ import {
60
+ ChangeRequestConflictsError,
61
+ DuplicateChangeRequestError,
62
+ RolesYamlPreservationError,
63
+ PushNeedsAgentResolutionError,
64
+ WorkflowValidationError,
65
+ } from './workflow.errors.js';
66
+ import { AccessDeniedError } from '../access/access-errors.js';
67
+
68
+ const execFileAsync = promisify(execFile);
69
+
70
+ /** The partial unique index that enforces one OPEN change request per (source, target) pair. */
71
+ const OPEN_PAIR_CONSTRAINT = 'change_requests_open_pair_unq';
72
+
73
+ /**
74
+ * True only for a Postgres unique violation (23505) on the open-CR-per-pair
75
+ * index. `change_requests` also carries `change_requests_number_unq` on its
76
+ * identity column, so we match the constraint NAME — a violation on any other
77
+ * unique index is a real integrity problem that must not be reported as a
78
+ * duplicate/open-close race.
79
+ */
80
+ function isOpenPairViolation(err: unknown): boolean {
81
+ // node-postgres puts `code` + `constraint` on the driver error; Drizzle
82
+ // sometimes wraps that error in `.cause`, so unwrap one level and check both.
83
+ const fieldOf = (e: unknown, key: 'code' | 'constraint' | 'message'): unknown =>
84
+ typeof e === 'object' && e !== null ? (e as Record<string, unknown>)[key] : undefined;
85
+ const cause = typeof err === 'object' && err !== null ? (err as { cause?: unknown }).cause : undefined;
86
+ const is23505 = fieldOf(err, 'code') === '23505' || fieldOf(cause, 'code') === '23505';
87
+ if (!is23505) return false;
88
+ // Prefer the structured `constraint` name; some pg drivers omit it, so fall
89
+ // back to sniffing the named index out of the message (same approach as
90
+ // db-routine.service's isUniqueViolation).
91
+ const constraint = fieldOf(err, 'constraint') ?? fieldOf(cause, 'constraint');
92
+ if (constraint === OPEN_PAIR_CONSTRAINT) return true;
93
+ const message = fieldOf(err, 'message') ?? fieldOf(cause, 'message');
94
+ return typeof message === 'string' && message.includes(OPEN_PAIR_CONSTRAINT);
95
+ }
96
+
97
+ /** Redact the shared GitHub token from any error string before surfacing it. */
98
+ function redactTokens(msg: string): string {
99
+ const tokens = [process.env.GITHUB_TOKEN, process.env.GH_TOKEN].filter(
100
+ (t): t is string => !!t && t.length > 0,
101
+ );
102
+ return tokens.reduce((m, t) => m.replaceAll(t, '***'), msg);
103
+ }
104
+
105
+ /**
106
+ * Cap on how many changed paths the `## Affected owners` CR-body block lists
107
+ * individually. The block is passed to `gh pr create --body`, an argv string,
108
+ * so an uncapped per-path list on a large changeset (hundreds/thousands of
109
+ * files) makes the whole command exceed the OS arg limit and fail with E2BIG.
110
+ */
111
+ export const MAX_AFFECTED_PATHS_LISTED = 50;
112
+
113
+ type EligibleWriters = Map<
114
+ string,
115
+ { roles: string[]; users: { name: string; email: string }[] }
116
+ >;
117
+
118
+ const formatWriter = (u: { name: string; email: string }): string =>
119
+ u.name ? `${u.name} <${u.email}>` : u.email;
120
+
121
+ /**
122
+ * Render the `## Affected owners` block for the CR body: eligible approvers per
123
+ * changed path, but only the first `maxListed` paths are listed individually;
124
+ * the overflow is collapsed into a single deduped summary of every eligible
125
+ * approver across the change. This keeps the body bounded regardless of how
126
+ * many files the change touches (see {@link MAX_AFFECTED_PATHS_LISTED}).
127
+ * Returns '' when no path has resolvable eligibility.
128
+ */
129
+ export function formatAffectedOwnersBlock(
130
+ paths: string[],
131
+ resolved: EligibleWriters,
132
+ maxListed: number = MAX_AFFECTED_PATHS_LISTED,
133
+ ): string {
134
+ const listed: string[] = [];
135
+ const allRoles = new Set<string>();
136
+ const allUsers = new Map<string, string>(); // email -> display, deduped
137
+ let eligible = 0;
138
+ for (const p of paths) {
139
+ const info = resolved.get(p);
140
+ if (!info) continue;
141
+ const parts: string[] = [];
142
+ if (info.roles.length > 0) {
143
+ info.roles.forEach((r) => allRoles.add(r));
144
+ parts.push(info.roles.join(', '));
145
+ }
146
+ if (info.users.length > 0) {
147
+ info.users.forEach((u) => allUsers.set(u.email, formatWriter(u)));
148
+ parts.push(info.users.map(formatWriter).join(', '));
149
+ }
150
+ if (parts.length === 0) continue;
151
+ eligible++;
152
+ if (listed.length < maxListed) listed.push(`- \`${p}\` — ${parts.join('; ')}`);
153
+ }
154
+ if (eligible === 0) return '';
155
+
156
+ const out = ['## Affected owners', '', ...listed];
157
+ if (eligible > listed.length) {
158
+ const summary = [
159
+ allRoles.size > 0 ? [...allRoles].sort().join(', ') : '',
160
+ allUsers.size > 0 ? [...allUsers.values()].sort().join(', ') : '',
161
+ ].filter((s) => s.length > 0);
162
+ out.push(
163
+ '',
164
+ `…and ${eligible - listed.length} more changed path(s). ` +
165
+ `All eligible approvers across this change: ${summary.join('; ')}`,
166
+ );
167
+ }
168
+ return out.join('\n');
169
+ }
170
+
171
+ export class WorkflowService implements IWorkflowService {
172
+ /**
173
+ * Concrete `PullRequestService` (not the interface) so the facade can call
174
+ * `invalidateDetailCache` after a mutation lands — the legacy routes do
175
+ * this inline, and the workflow facade absorbs that responsibility so its
176
+ * own routes stay free of cache plumbing. Replaceable later when the PR
177
+ * detail cache moves into the workflow module proper (PLAN step 5).
178
+ *
179
+ * `db` + `workspaceService` + `kbDirName` back `openChangeRequest`, which
180
+ * inserts a change_requests row and runs the local auto-merge — no provider
181
+ * PR API, so the KB can live on any git host.
182
+ */
183
+ /**
184
+ * Concrete `GitService` (not the interface) so the facade can reach
185
+ * `mergeFromOrigin` — the local auto-merge primitive the workflow needs
186
+ * for `openChangeRequest` + `updateFromTarget` (PLAN §1). The interface
187
+ * intentionally doesn't surface that method because it's an internal
188
+ * implementation detail of the workflow auto-merge story; nothing outside
189
+ * the facade should call it directly.
190
+ */
191
+ constructor(
192
+ private readonly db: Database,
193
+ private readonly git: GitService,
194
+ private readonly prs: PullRequestService,
195
+ private readonly reviewWorkflow: IReviewWorkflowService,
196
+ private readonly workspaceService: WorkspaceService,
197
+ private readonly accessControl: IAccessControl,
198
+ private readonly fileLocks: FileLockService,
199
+ /**
200
+ * Background commit queue. `releaseLock` enqueues a row instead of
201
+ * committing inline; the `PendingCommitsWorker` drains the queue and
202
+ * runs commit + push out of band. This keeps git's latency and
203
+ * failure modes entirely off the user-facing lock release path.
204
+ * See `lock-decoupling-plan.md` for the full design.
205
+ */
206
+ private readonly pendingCommits: PendingCommitsService,
207
+ private readonly kbDirName: string,
208
+ /**
209
+ * Event bus that fans state changes out to connected SSE sessions.
210
+ * Optional so test harnesses / minimal boots can omit it — emits
211
+ * become no-ops in that case (we null-guard each call). In a real
212
+ * boot the composition root always wires one.
213
+ */
214
+ private readonly events?: WorkflowEventBus,
215
+ /**
216
+ * In-process post-commit hook (distinct from the SSE `events` bus): fires
217
+ * after a file's change is committed + pushed so backend services (catalog
218
+ * invalidation, id-repair) react without the per-file TTL wait. Optional,
219
+ * like `events`.
220
+ */
221
+ private readonly fileChanges?: FileChangeNotifier,
222
+ /**
223
+ * Workflow lifecycle-hook registry (see `workflow-hooks.ts`). PUBLIC so
224
+ * the composition root registers module-owned behavior against it
225
+ * (`core.workflowService.hooks.onCommitValidation(…)` /
226
+ * `.onPreWrite(…)`) after `createCoreServices` returns. The composition
227
+ * root passes the SAME instance `GitService` and the session-ontology
228
+ * gate consult; the default keeps test constructions (which don't
229
+ * exercise hooks) unchanged.
230
+ */
231
+ public readonly hooks: WorkflowHooks = new WorkflowHooks(),
232
+ ) {}
233
+
234
+ // ── Branches ──────────────────────────────────────────────────────────────
235
+
236
+ /**
237
+ * One-shot guard so the orphan-workspace sweep fires once per process,
238
+ * not on every listBranches call. The sweep runs in the background
239
+ * after the first list returns — failures are swallowed and logged.
240
+ */
241
+ private orphanSweepStarted = false;
242
+
243
+ async listBranches(workspaceId: string, opts?: { freshFetch?: boolean }): Promise<Branch[]> {
244
+ const branches = await this.git.listBranches(workspaceId, opts);
245
+ if (!this.orphanSweepStarted) {
246
+ this.orphanSweepStarted = true;
247
+ // Fire-and-forget. The sweep is best-effort cleanup of dirs whose
248
+ // branches no longer exist on origin; nothing else depends on it
249
+ // completing inside the listBranches window.
250
+ this.workspaceService
251
+ .sweepOrphanedWorkspaces(branches.map((b) => b.name))
252
+ .then(({ removed }) => {
253
+ if (removed.length > 0) {
254
+ console.log(`[workflow] swept ${removed.length} orphaned workspace(s):`, removed);
255
+ }
256
+ })
257
+ .catch((err) => {
258
+ console.warn('[workflow] orphan workspace sweep failed:', err);
259
+ });
260
+ }
261
+ return branches;
262
+ }
263
+
264
+ createBranch(workspaceId: string, name: string, fromBase?: string): Promise<Branch> {
265
+ return this.git.createBranch(workspaceId, name, fromBase);
266
+ }
267
+
268
+ deleteBranch(
269
+ workspaceId: string,
270
+ name: string,
271
+ user: AuthUser,
272
+ opts?: { onlyIfNoRemote?: boolean },
273
+ ): Promise<void> {
274
+ return this.git.deleteBranch(workspaceId, name, user, opts);
275
+ }
276
+
277
+ // No `switchBranch` here on purpose. Under the per-branch workspace model,
278
+ // switching branches is a frontend navigation that triggers a workspace
279
+ // bootstrap (cloning the new branch into its own dir). The backend no
280
+ // longer exposes a "checkout this branch on the source workspace's clone"
281
+ // operation, which was the only place the legacy DirtyWorkingTreeError
282
+ // could surface as a user-facing 409.
283
+
284
+ // `forkCurrentToDraft` removed (see IWorkflowService): per-branch workspaces
285
+ // make the dirty-tree-carry-along escape hatch impossible by construction.
286
+
287
+ branchStatus(workspaceId: string): Promise<BranchWorkspaceStatus> {
288
+ return this.git.status(workspaceId);
289
+ }
290
+
291
+ // `discardWorkingChanges` removed: under save=share there's nothing dirty
292
+ // to discard.
293
+
294
+ shareCurrentBranch(workspaceId: string, user: AuthUser): Promise<void> {
295
+ return this.git.push(workspaceId, user);
296
+ }
297
+
298
+ refreshRemotes(workspaceId: string): Promise<void> {
299
+ return this.git.fetch(workspaceId);
300
+ }
301
+
302
+ updateFromRemote(workspaceId: string): Promise<void> {
303
+ return this.git.pull(workspaceId);
304
+ }
305
+
306
+ resetToRemote(workspaceId: string, branch: string): Promise<void> {
307
+ return this.git.resetToRemote(workspaceId, branch);
308
+ }
309
+
310
+ resolveForkBase(workspaceId: string, branch: string): Promise<string | null> {
311
+ return this.git.resolveForkBase(workspaceId, branch);
312
+ }
313
+
314
+ countCommitsAhead(workspaceId: string, branch: string, baseBranch: string): Promise<number> {
315
+ return this.git.countCommitsAhead(workspaceId, branch, baseBranch);
316
+ }
317
+
318
+ listPendingChangePaths(workspaceId: string): Promise<string[]> {
319
+ return this.git.pendingChanges(workspaceId);
320
+ }
321
+
322
+ // `listWorkingChanges` + `diffFileInWorking` removed: working-tree dirty
323
+ // state isn't a meaningful surface under save=share.
324
+
325
+ // ── Changes ───────────────────────────────────────────────────────────────
326
+
327
+ commitChange(workspaceId: string, user: AuthUser, input: ChangeInput): Promise<Change> {
328
+ return this.git.commit(workspaceId, user, input);
329
+ }
330
+
331
+ /**
332
+ * Atomic multi-file commit + push — commits whatever the caller has already
333
+ * written to the working tree as ONE change attributed to `user`, then pushes
334
+ * so collaborators on the branch see it (same commit-then-push shape as the
335
+ * autosave path). Returns null if the tree is clean (no-op). Behind the bulk
336
+ * node-upload flow and the role-rename rewrite (via `LockingFilesystem`) — both
337
+ * write their files first, then call this. Does NOT write content itself; the
338
+ * caller owns the disk write (and any content validation).
339
+ */
340
+ async commitChanges(
341
+ workspaceId: string,
342
+ user: AuthUser,
343
+ summary: string,
344
+ ): Promise<Change | null> {
345
+ const change = await this.git.commitChanges(workspaceId, user, summary);
346
+ // Same recovery as the per-file queue: a bare push here could strand the
347
+ // just-made local commit (committed but never shared — breaking save=share)
348
+ // on any non-fast-forward race. Pull-rebase + retry recovers the common
349
+ // case; a genuine failure still throws the typed agent-handoff error, with
350
+ // the commit intact for the recovery flow — exactly like `runPendingCommit`.
351
+ // Workspace id IS the encoded branch name (see workspace.service.ts).
352
+ if (change) {
353
+ await this.pushWithRecovery(workspaceId, decodeURIComponent(workspaceId), '(batch)', user);
354
+ }
355
+ return change;
356
+ }
357
+
358
+ listChangesForFile(workspaceId: string, path: string, limit?: number): Promise<Change[]> {
359
+ return this.git.logForFile(workspaceId, path, limit);
360
+ }
361
+
362
+ revertChange(workspaceId: string, user: AuthUser, sha: string): Promise<Change> {
363
+ return this.git.revertCommit(workspaceId, user, sha);
364
+ }
365
+
366
+ compareFile(
367
+ workspaceId: string,
368
+ path: string,
369
+ fromBranch: string,
370
+ toBranch: string,
371
+ ): Promise<string> {
372
+ return this.git.diffFileBetweenBranches(workspaceId, path, fromBranch, toBranch);
373
+ }
374
+
375
+ showFileAtChange(workspaceId: string, path: string, sha: string): Promise<string> {
376
+ return this.git.diffFileAtCommit(workspaceId, path, sha);
377
+ }
378
+
379
+ /**
380
+ * Resolve write permission for `targetPath` against the access tree at
381
+ * `HEAD` of `branch`. Throws `AccessDeniedError` on denial with the
382
+ * eligible-writers payload attached. Bootstrap-friendly: when no rules are
383
+ * in force at the ref yet, treats the call as allow (matches the legacy
384
+ * commit-time gate's behavior).
385
+ *
386
+ * Always reads at-ref, never at the working tree, so a user can't broaden
387
+ * their own access by editing `roles.yaml` / `access.md` in the same
388
+ * session.
389
+ */
390
+ private async assertCanWriteAtPath(
391
+ workspaceId: string,
392
+ branch: string,
393
+ userEmail: string,
394
+ targetPath: string,
395
+ ): Promise<void> {
396
+ // The lock route passes workspace-relative paths
397
+ // (`knowledge-base/GTM/.../Foo.md`), but the access model is keyed by
398
+ // *repo-relative* paths — `git ls-tree` runs inside the inner repo dir,
399
+ // so its access.md entries are at `GTM/.../access.md`, not
400
+ // `knowledge-base/GTM/.../access.md`. Without stripping the kbDirName
401
+ // prefix here, the chain walk only ever matches root access.md, which
402
+ // typically only grants Admin → non-admins get 403'd on files their
403
+ // role's nested access.md actually permits. Mirrors the frontend's
404
+ // `useFileAccess` prefix-strip on the way INTO the API.
405
+ const repoRelative = targetPath.startsWith(`${this.kbDirName}/`)
406
+ ? targetPath.slice(this.kbDirName.length + 1)
407
+ : targetPath;
408
+ const result = await this.accessControl.canWriteBatchAtRef(
409
+ workspaceId,
410
+ `HEAD`,
411
+ userEmail,
412
+ [repoRelative],
413
+ );
414
+ if (!result) return; // no config at ref → default-allow (bootstrap)
415
+ if (result.get(repoRelative)) return;
416
+ const eligible = await this.accessControl.eligibleWritersAtRef(
417
+ workspaceId,
418
+ `HEAD`,
419
+ repoRelative,
420
+ );
421
+ throw new AccessDeniedError({
422
+ path: targetPath,
423
+ eligibleRoles: eligible?.roles ?? [],
424
+ eligibleUsers: eligible?.users ?? [],
425
+ });
426
+ }
427
+
428
+ // ── File locks ────────────────────────────────────────────────────────────
429
+
430
+ async acquireLock(
431
+ workspaceId: string,
432
+ branch: string,
433
+ targetPath: string,
434
+ user: AuthUser,
435
+ ): Promise<AcquireLockResult> {
436
+ // **Permission check at lock acquisition, not at commit time.** Under the
437
+ // "disk is the source of truth" rule, once a write has landed on disk we
438
+ // must never reject the commit that publishes it — otherwise we'd be
439
+ // destroying the user's work to satisfy a permission rule that should
440
+ // have been enforced earlier. The earliest correct point is HERE, before
441
+ // the user starts editing: if they can't write the file, they don't get
442
+ // the lock and the editor never opens. Once the lock is in hand, the
443
+ // commit + push pipeline does not re-check (see `commitFile`).
444
+ //
445
+ // **Protected branches only** (mirrors the legacy commit-time gate). On
446
+ // feature/draft branches anyone can write; canonical state changes go
447
+ // through change-request approval, which is where the real security
448
+ // boundary lives. Checking at HEAD (not at the working tree) so a user
449
+ // can't grant themselves access by editing `roles.yaml` in the same
450
+ // session.
451
+ if (isProtectedBranch(branch)) {
452
+ // `assertCanWriteAtPath` throws AccessDeniedError on denial, with the
453
+ // eligible-writers payload so the frontend can render a useful refusal.
454
+ await this.assertCanWriteAtPath(workspaceId, branch, user.email, targetPath);
455
+ }
456
+ const result = await this.fileLocks.acquire(workspaceId, branch, targetPath, user);
457
+ if (result.acquired) {
458
+ console.log(
459
+ `[lock] ACQUIRE ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → acquired`,
460
+ );
461
+ // Only fire on successful acquisition — a contention "false" return
462
+ // means nothing observable changed for other users (someone else
463
+ // already held the lock and still does). Stale-lock takeover is also
464
+ // an acquisition from the perspective of the rest of the UI.
465
+ this.events?.emit({
466
+ kind: 'lock-acquired',
467
+ workspaceId,
468
+ branch,
469
+ path: targetPath,
470
+ holderUserId: user.id,
471
+ holderName: user.name,
472
+ });
473
+ } else {
474
+ console.log(
475
+ `[lock] ACQUIRE ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → contended, held by ${result.lock.holderName} (${result.lock.holderUserId})`,
476
+ );
477
+ }
478
+ return result;
479
+ }
480
+
481
+ async heartbeatLock(
482
+ workspaceId: string,
483
+ branch: string,
484
+ targetPath: string,
485
+ user: AuthUser,
486
+ ): Promise<FileLock> {
487
+ try {
488
+ const lock = await this.fileLocks.heartbeat(workspaceId, branch, targetPath, user);
489
+ console.log(
490
+ `[lock] HEARTBEAT ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → ok (expires ${lock.expiresAt})`,
491
+ );
492
+ return lock;
493
+ } catch (err) {
494
+ console.warn(
495
+ `[lock] HEARTBEAT ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → FAIL ${err instanceof Error ? err.message : err}`,
496
+ );
497
+ throw err;
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Commit the lock-held file as a change *without* releasing the lock.
503
+ * The autosave checkpoint timer drives this — periodic commits land
504
+ * while the user keeps editing. Refuses when the caller doesn't hold
505
+ * the lock so a stale client can't sneak commits through after their
506
+ * lock expired and another user took over.
507
+ */
508
+ async commitFileWhileLocked(
509
+ workspaceId: string,
510
+ branch: string,
511
+ targetPath: string,
512
+ user: AuthUser,
513
+ summary?: string,
514
+ ): Promise<Change | null> {
515
+ const lock = await this.fileLocks.get(workspaceId, branch, targetPath);
516
+ if (!lock || lock.holderUserId !== user.id) {
517
+ throw new WorkflowValidationError(
518
+ `Cannot commit "${targetPath}": you don't hold the lock.`,
519
+ { kind: 'lock-not-held', branch, path: targetPath },
520
+ );
521
+ }
522
+ const change = await this.git.commitFile(workspaceId, user, targetPath, summary);
523
+ // Push the autosave commit so other users on the branch see it
524
+ // without waiting for the eventual lock release. Best-effort, same
525
+ // semantics as the push in `releaseLock`.
526
+ if (change) {
527
+ try {
528
+ await this.git.push(workspaceId, user);
529
+ } catch (err) {
530
+ // Non-fast-forward recovery on the autosave path: try the
531
+ // cooperative `pull --rebase` + retry once. Autosave is
532
+ // best-effort and runs every minute while the lock is held —
533
+ // if cooperative recovery fails (the divergence conflicts with
534
+ // our edit), we DON'T auto-force-push (would destroy teammate
535
+ // commits) and we DON'T surface to the agent yet (the user is
536
+ // still typing — agent involvement would be premature). The
537
+ // eventual `releaseLock` push will hit the same condition and
538
+ // surface a structured `PushNeedsAgentResolutionError` at the
539
+ // moment the user actually closes their edit, which is when
540
+ // the agent should step in.
541
+ const detail = err instanceof Error ? err.message : String(err);
542
+ const looksLikeNonFastForward = /non-fast-forward|rejected|fetch first|updates were rejected/i.test(detail);
543
+ let recovered = false;
544
+ if (looksLikeNonFastForward) {
545
+ try {
546
+ await this.git.pull(workspaceId);
547
+ await this.git.push(workspaceId, user);
548
+ recovered = true;
549
+ } catch (recoveryErr) {
550
+ console.warn(
551
+ '[workflow] autosave cooperative recovery (pull-rebase) failed; leaving the unpushed commit for the next save / releaseLock to surface:',
552
+ recoveryErr instanceof Error ? recoveryErr.message : recoveryErr,
553
+ );
554
+ }
555
+ }
556
+ if (!recovered) {
557
+ console.warn(
558
+ '[workflow] push after autosave commit failed (commit landed locally):',
559
+ detail,
560
+ );
561
+ }
562
+ }
563
+ this.events?.emit({
564
+ kind: 'file-changed',
565
+ workspaceId,
566
+ branch,
567
+ path: targetPath,
568
+ newSha: change.sha,
569
+ byUserId: user.id,
570
+ byUserName: user.name,
571
+ });
572
+ }
573
+ return change;
574
+ }
575
+
576
+ /**
577
+ * Release the lock AND commit the file's pending edits as one atomic
578
+ * change. The lock guarantees the file is the only one this caller has
579
+ * been editing, so we use the path-scoped `commitFile` rather than the
580
+ * one-file-per-change-enforcing `commit` — other dirty files in the
581
+ * workspace (e.g. concurrent agent edits on a separate flow) are left
582
+ * alone. Returns `null` when the file had no pending edits, so opening
583
+ * a lock + closing without typing is a clean no-op.
584
+ */
585
+ /**
586
+ * Release a lock the caller holds. **Does NOT commit or push** — those
587
+ * happen out of band in the `PendingCommitsWorker` after this method
588
+ * enqueues a row. The user-visible contract is:
589
+ *
590
+ * 1. Bytes the caller wrote inside the lock are on disk in the shared
591
+ * per-branch workspace.
592
+ * 2. All same-workspace SSE subscribers see the new bytes via the
593
+ * `fs-tree-changed` event the upload routes already emit.
594
+ * 3. Git history catches up later, asynchronously.
595
+ *
596
+ * This is the structural fix for the bug class that stranded files on
597
+ * disk whenever the inline commit failed (transient git error, process
598
+ * SIGTERM mid-commit, etc.). See `lock-decoupling-plan.md`.
599
+ *
600
+ * Signature change from the previous shape: no more `summary` /
601
+ * `skipValidator` / `skipPush` / return value. Summary is computed by
602
+ * the worker (`buildDefaultCommitSummary(path)`); validator runs in the
603
+ * worker; push is the worker's job; there's no synchronous `Change`
604
+ * object to return because the commit hasn't happened yet.
605
+ */
606
+ async releaseLock(
607
+ workspaceId: string,
608
+ branch: string,
609
+ targetPath: string,
610
+ user: AuthUser,
611
+ ): Promise<void> {
612
+ // Ownership check — the lock service's `release` is idempotent and
613
+ // would silently no-op for a non-holder, but we'd still enqueue a
614
+ // commit attributed to whoever called us. The guard rejects callers
615
+ // who don't actually hold the row so impersonation can't enqueue
616
+ // commits as a third party.
617
+ console.log(
618
+ `[lock] RELEASE start ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id}`,
619
+ );
620
+ const lock = await this.fileLocks.get(workspaceId, branch, targetPath);
621
+ if (!lock || lock.holderUserId !== user.id) {
622
+ console.warn(
623
+ `[lock] RELEASE refused ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → ${lock ? `held by ${lock.holderName} (${lock.holderUserId})` : 'no lock row'}`,
624
+ );
625
+ throw new WorkflowValidationError(
626
+ `Cannot release lock on "${targetPath}": not held by you (or no longer exists).`,
627
+ { kind: 'lock-not-held', branch, path: targetPath },
628
+ );
629
+ }
630
+ // Order matters here:
631
+ // 1. Drop the lock row. Other sessions can re-acquire immediately.
632
+ // 2. Enqueue the commit. Worker will pick it up on next sweep.
633
+ // 3. Emit `lock-released` SSE.
634
+ //
635
+ // We swap (1) and (2) — enqueue first, drop second — so a process
636
+ // crash between the two leaves the lock held (TTL will release it)
637
+ // rather than dropping the lock without queueing the commit (which
638
+ // would re-introduce the orphan-files class of bug this refactor
639
+ // exists to fix). Worst case: brief over-lock that resolves on TTL.
640
+ await this.pendingCommits.enqueue({
641
+ workspaceId,
642
+ branch,
643
+ path: targetPath,
644
+ authorEmail: user.email,
645
+ authorName: user.name,
646
+ });
647
+ await this.fileLocks.release(workspaceId, branch, targetPath, user);
648
+ console.log(
649
+ `[lock] RELEASE done ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} (commit queued for background worker)`,
650
+ );
651
+ // The `file-changed` SSE event used to fire here after the synchronous
652
+ // commit landed, carrying the new sha. Under the queue model the
653
+ // commit hasn't happened yet — the worker emits `file-changed` itself
654
+ // (with the resolved sha) when its commit lands. Same downstream
655
+ // semantics for the frontend, just deferred by the queue's drain
656
+ // latency (typically < 1s).
657
+ this.events?.emit({
658
+ kind: 'lock-released',
659
+ workspaceId,
660
+ branch,
661
+ path: targetPath,
662
+ });
663
+ }
664
+
665
+ /**
666
+ * Worker entry point: commit whatever's currently on disk for `path`,
667
+ * then push the branch. This is the body that used to live inline in
668
+ * `releaseLock` — extracted so the background queue runs the same
669
+ * commit + cooperative-push pipeline a synchronous save once did.
670
+ *
671
+ * Throws on commit / push failure so the worker can decide whether to
672
+ * retry, hand off to the recovery agent, or escalate. Emits
673
+ * `file-changed` on success so frontends see the new sha.
674
+ */
675
+ async runPendingCommit(
676
+ workspaceId: string,
677
+ branch: string,
678
+ targetPath: string,
679
+ user: AuthUser,
680
+ ): Promise<void> {
681
+ const change = await this.git.commitFile(workspaceId, user, targetPath);
682
+ if (!change) {
683
+ // No-op commit (path was already clean — typical for a double-enqueue
684
+ // or a save of bytes identical to HEAD). Nothing to push; nothing to
685
+ // emit. The worker treats this as success and drops the row.
686
+ return;
687
+ }
688
+ await this.pushWithRecovery(workspaceId, branch, targetPath, user);
689
+ this.events?.emit({
690
+ kind: 'file-changed',
691
+ workspaceId,
692
+ branch,
693
+ path: targetPath,
694
+ newSha: change.sha,
695
+ byUserId: user.id,
696
+ byUserName: user.name,
697
+ });
698
+ // Post-commit, post-lock-release: the one chokepoint every single-file write
699
+ // converges on. Backend reactors (catalog invalidation, id-repair) hook here.
700
+ this.fileChanges?.emit({ workspaceId, branch, paths: [targetPath], byUser: user });
701
+ }
702
+
703
+ /**
704
+ * Shared implementation of "push, and on non-fast-forward try a
705
+ * cooperative pull-rebase + retry, then hand off to the agent if that
706
+ * still fails." The full rationale lives at the call site in
707
+ * `releaseLock`; this method is the de-duplicated body.
708
+ */
709
+ private async pushWithRecovery(
710
+ workspaceId: string,
711
+ branch: string,
712
+ targetPath: string,
713
+ user: AuthUser,
714
+ ): Promise<void> {
715
+ try {
716
+ await this.git.push(workspaceId, user);
717
+ } catch (firstPushErr) {
718
+ const firstDetail = firstPushErr instanceof Error ? firstPushErr.message : String(firstPushErr);
719
+ const looksLikeNonFastForward = /non-fast-forward|rejected|fetch first|updates were rejected/i.test(firstDetail);
720
+ let recovered = false;
721
+ let recoveryDetail = '(cooperative path not attempted)';
722
+ if (looksLikeNonFastForward) {
723
+ try {
724
+ await this.git.pull(workspaceId);
725
+ await this.git.push(workspaceId, user);
726
+ recovered = true;
727
+ console.log(
728
+ `[workflow] non-fast-forward push recovered via pull --rebase for workspace=${workspaceId} branch=${branch} path=${targetPath}`,
729
+ );
730
+ } catch (recoveryErr) {
731
+ recoveryDetail = recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr);
732
+ console.warn(
733
+ `[workflow] cooperative recovery (pull-rebase) failed for workspace=${workspaceId} user=${user.id}; handing off to agent:`,
734
+ recoveryDetail,
735
+ );
736
+ }
737
+ }
738
+ if (!recovered) {
739
+ console.warn(
740
+ `[workflow] push failed for workspace=${workspaceId} user=${user.id}; throwing PushNeedsAgentResolutionError so the frontend can hand off to the agent:`,
741
+ firstDetail,
742
+ );
743
+ throw new PushNeedsAgentResolutionError(branch, targetPath, firstDetail, recoveryDetail);
744
+ }
745
+ }
746
+ }
747
+
748
+ /**
749
+ * Failure-path counterpart to `releaseLock`. Skips `commitFile` + push
750
+ * entirely so a half-finished write doesn't accidentally persist its
751
+ * partial state as a committed change. The lock row goes away (so the
752
+ * next caller can edit the file); whatever's on disk stays — the next
753
+ * acquirer will see it. `LockingFilesystem.withLock` calls this from
754
+ * its catch arm so a thrown op (path traversal, validator 422,
755
+ * permission denied) doesn't end up committing.
756
+ */
757
+ async releaseLockNoCommit(
758
+ workspaceId: string,
759
+ branch: string,
760
+ targetPath: string,
761
+ user: AuthUser,
762
+ ): Promise<void> {
763
+ // Verify ownership first so the emit only fires when something
764
+ // observable actually changed. `fileLocks.release` silently no-ops
765
+ // when the caller doesn't hold the row (idempotent-by-design), so
766
+ // without this guard a non-holder calling this method would emit
767
+ // `lock-released` and trick every other client into clearing their
768
+ // "Locked by X" banner even though the lock is still held.
769
+ console.log(
770
+ `[lock] RELEASE-NO-COMMIT start ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id}`,
771
+ );
772
+ const lock = await this.fileLocks.get(workspaceId, branch, targetPath);
773
+ if (!lock || lock.holderUserId !== user.id) {
774
+ console.log(
775
+ `[lock] RELEASE-NO-COMMIT no-op ws=${workspaceId} path=${targetPath} → ${lock ? `held by ${lock.holderName}` : 'no lock row'}`,
776
+ );
777
+ return;
778
+ }
779
+ // **Discard the file's working-tree changes before dropping the lock.**
780
+ // The whole point of this method is "release without committing" —
781
+ // historically that meant "leave partial bytes on disk", but the user-
782
+ // visible consequence was a chronically-dirty working tree that
783
+ // blocked branch switches and could be silently committed by the next
784
+ // acquirer under the wrong author. The system's invariant is now:
785
+ // every release leaves the working tree clean. If we're not
786
+ // committing the bytes, we're throwing them away.
787
+ try {
788
+ await this.git.discardPath(workspaceId, targetPath);
789
+ } catch (err) {
790
+ // Best-effort: a discard failure is logged but doesn't block the
791
+ // lock release. Worst case the working tree stays dirty for one
792
+ // path until the next save on it cleans up.
793
+ console.warn(
794
+ `[workflow] discardPath failed for workspace=${workspaceId} branch=${branch} path=${targetPath}:`,
795
+ err instanceof Error ? err.message : err,
796
+ );
797
+ }
798
+ await this.fileLocks.release(workspaceId, branch, targetPath, user);
799
+ console.log(
800
+ `[lock] RELEASE-NO-COMMIT done ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → discarded + released`,
801
+ );
802
+ this.events?.emit({
803
+ kind: 'lock-released',
804
+ workspaceId,
805
+ branch,
806
+ path: targetPath,
807
+ });
808
+ // The disk just changed (revert / removal). Tell anyone watching so
809
+ // their open tabs refresh to the post-discard state.
810
+ this.events?.emit({
811
+ kind: 'file-changed',
812
+ workspaceId,
813
+ branch,
814
+ path: targetPath,
815
+ newSha: null,
816
+ byUserId: user.id,
817
+ byUserName: user.name,
818
+ });
819
+ }
820
+
821
+ getLock(
822
+ workspaceId: string,
823
+ branch: string,
824
+ targetPath: string,
825
+ ): Promise<FileLock | null> {
826
+ return this.fileLocks.get(workspaceId, branch, targetPath);
827
+ }
828
+
829
+ // ── Change Requests ───────────────────────────────────────────────────────
830
+
831
+ listChangeRequests(opts?: { fresh?: boolean }): Promise<ChangeRequest[]> {
832
+ return this.prs.listOpenPrs(opts);
833
+ }
834
+
835
+ listChangeRequestsAuthoredBy(emailOrLogin: string): Promise<ChangeRequest[]> {
836
+ return this.prs.listPrsAuthoredBy(emailOrLogin);
837
+ }
838
+
839
+ listChangeRequestsForUser(
840
+ workspaceId: string,
841
+ email: string,
842
+ opts?: { fresh?: boolean },
843
+ ): Promise<ChangeRequest[]> {
844
+ return this.prs.listPrsForOwnerEmail(workspaceId, email, opts);
845
+ }
846
+
847
+ getChangeRequest(number: number): Promise<ChangeRequest | null> {
848
+ return this.prs.getPr(number);
849
+ }
850
+
851
+ getChangeRequestDetail(
852
+ number: number,
853
+ opts?: { fresh?: boolean; workspaceId?: string; viewerEmail?: string },
854
+ ): Promise<ChangeRequestDetail | null> {
855
+ return this.prs.getPrDetail(number, opts);
856
+ }
857
+
858
+ /**
859
+ * Open a new change request. A change request is a plain `change_requests`
860
+ * row plus the two branches — no provider PR — so any git host works.
861
+ *
862
+ * 1. Validation — non-empty branches, distinct branches, source is not
863
+ * protected, title present.
864
+ * 2. CR uniqueness — refuse if an open CR already exists for the same
865
+ * `(source, target)` pair (A→B blocks A→B; B→A is allowed).
866
+ * 3. Auto-merge — `git merge origin/<target>` into `<source>` locally,
867
+ * so the resulting CR shows the post-merge diff. Conflicts surface
868
+ * as `ChangeRequestConflictsError` (409 with the conflicting paths)
869
+ * so the caller (agent or UI) can route into the resolution flow.
870
+ * 4. Push the source branch so the remote carries every commit the CR
871
+ * will reference, including the auto-merge commit if any.
872
+ * 5. Insert the `change_requests` row (author stored directly — no service
873
+ * account, so no hidden body marker). The DB-assigned number is the CR id.
874
+ * 6. Re-fetch the full detail via the PR service so the response shape
875
+ * matches every other CR endpoint.
876
+ */
877
+ async openChangeRequest(
878
+ workspaceId: string,
879
+ user: AuthUser,
880
+ input: OpenChangeRequestInput,
881
+ ): Promise<ChangeRequestDetail> {
882
+ if (!input.sourceBranch) throw new WorkflowValidationError('sourceBranch is required');
883
+ if (!input.targetBranch) throw new WorkflowValidationError('targetBranch is required');
884
+ if (input.sourceBranch === input.targetBranch) {
885
+ throw new WorkflowValidationError('source and target branches must differ');
886
+ }
887
+ if (!input.title?.trim()) throw new WorkflowValidationError('title is required');
888
+ if (isProtectedBranch(input.sourceBranch)) {
889
+ throw new WorkflowValidationError(
890
+ `Cannot open a change request *from* a protected branch ("${input.sourceBranch}").`,
891
+ { kind: 'source-is-protected', sourceBranch: input.sourceBranch },
892
+ );
893
+ }
894
+
895
+ // Uniqueness rule — A→B blocks A→B (the spec is explicit that B→A in
896
+ // parallel is still allowed). `listOpenPrs` is cached for 30s so we
897
+ // force-fresh to catch a CR that opened just now.
898
+ const openCrs = await this.prs.listOpenPrs({ fresh: true });
899
+ const duplicate = openCrs.find(
900
+ (cr) => cr.branch === input.sourceBranch && cr.base === input.targetBranch,
901
+ );
902
+ if (duplicate) {
903
+ throw new DuplicateChangeRequestError(
904
+ input.sourceBranch,
905
+ input.targetBranch,
906
+ duplicate.number,
907
+ );
908
+ }
909
+
910
+ // Auto-merge target → source. `mergeFromOrigin` enforces "current
911
+ // branch == source" and "clean working tree" before attempting; both
912
+ // failures surface as structured workflow errors that pass straight
913
+ // through to the caller.
914
+ const mergeOutcome = await this.git.mergeFromOrigin(
915
+ workspaceId,
916
+ input.sourceBranch,
917
+ input.targetBranch,
918
+ user,
919
+ );
920
+ if (mergeOutcome.kind === 'conflicts') {
921
+ throw new ChangeRequestConflictsError(
922
+ input.sourceBranch,
923
+ input.targetBranch,
924
+ mergeOutcome.paths,
925
+ );
926
+ }
927
+
928
+ // Push the source — `gh pr create` against an unpushed branch returns
929
+ // "head branch does not exist on remote". Push *after* the auto-merge
930
+ // so the remote sees the merge commit too.
931
+ await this.git.push(workspaceId, user);
932
+
933
+ const workspacePath = await this.workspaceService.getWorkspacePath(workspaceId);
934
+ const cwd = path.join(workspacePath, this.kbDirName);
935
+
936
+ // Touched paths between target and source — basis for the
937
+ // affected-owners block we append to the CR body. We compute it
938
+ // ourselves rather than waiting for GitHub to expose `pr.files`
939
+ // (which only populates after the PR exists). Three-dot range
940
+ // matches what `gh pr create` will eventually show as the diff.
941
+ const touchedPaths = await this.listChangedPathsBetweenBranches(
942
+ cwd,
943
+ input.targetBranch,
944
+ input.sourceBranch,
945
+ );
946
+ const affectedOwnersBlock = await this.buildAffectedOwnersBlock(
947
+ workspaceId,
948
+ input.targetBranch,
949
+ touchedPaths,
950
+ );
951
+
952
+ // The change request is a plain DB row now — no provider PR. Author is a
953
+ // real column (no hidden body marker needed, since nothing runs as a shared
954
+ // service account anymore).
955
+ const description = input.description?.trim() ?? '';
956
+ const body = [description, affectedOwnersBlock].filter((s) => s.length > 0).join('\n\n');
957
+
958
+ let prNumber: number;
959
+ try {
960
+ const [row] = await this.db
961
+ .insert(changeRequests)
962
+ .values({
963
+ sourceBranch: input.sourceBranch,
964
+ targetBranch: input.targetBranch,
965
+ title: input.title.trim(),
966
+ body,
967
+ authorEmail: user.email.trim().toLowerCase(),
968
+ authorName: user.name,
969
+ })
970
+ .returning({ number: changeRequests.number });
971
+ prNumber = row.number;
972
+ } catch (err) {
973
+ // The partial unique index (source, target WHERE state='open') is the
974
+ // race-proof backstop for the pre-check above: a concurrent open of the
975
+ // same pair lands here. Surface it as the same duplicate error, with the
976
+ // winning CR's number. Any OTHER unique violation (e.g. the identity
977
+ // `change_requests_number_unq`) is a genuine integrity fault and falls
978
+ // through to the generic path below rather than being mislabeled.
979
+ if (isOpenPairViolation(err)) {
980
+ const [existing] = await this.db
981
+ .select({ number: changeRequests.number })
982
+ .from(changeRequests)
983
+ .where(
984
+ and(
985
+ eq(changeRequests.sourceBranch, input.sourceBranch),
986
+ eq(changeRequests.targetBranch, input.targetBranch),
987
+ eq(changeRequests.state, 'open'),
988
+ ),
989
+ )
990
+ .limit(1);
991
+ // Normally the conflicting open CR is right there. If it vanished (the
992
+ // rival was merged/closed in the microsecond between the conflict and
993
+ // this re-query), don't fabricate a #0 — report it as a transient race.
994
+ if (!existing) {
995
+ throw new WorkflowValidationError(
996
+ `Change request for "${input.sourceBranch}" → "${input.targetBranch}" ` +
997
+ 'raced with a concurrent open/close; retry.',
998
+ { kind: 'open-change-request-failed' },
999
+ );
1000
+ }
1001
+ throw new DuplicateChangeRequestError(
1002
+ input.sourceBranch,
1003
+ input.targetBranch,
1004
+ existing.number,
1005
+ );
1006
+ }
1007
+ // Non-uniqueness failure AFTER the auto-merge + push already advanced the
1008
+ // source branch: no `change_requests` row was created, but the source now
1009
+ // carries the auto-merge commit. This is safely retryable — re-running
1010
+ // openChangeRequest finds no duplicate, re-runs the (idempotent, already
1011
+ // up-to-date) auto-merge, and inserts the row.
1012
+ const rawMsg = err instanceof Error ? err.message : String(err);
1013
+ throw new WorkflowValidationError(
1014
+ `Failed to open change request: ${redactTokens(rawMsg)}`,
1015
+ { kind: 'open-change-request-failed' },
1016
+ );
1017
+ }
1018
+ this.prs.invalidateDetailCache(prNumber);
1019
+
1020
+ const detail = await this.prs.getPrDetail(prNumber, {
1021
+ fresh: true,
1022
+ workspaceId,
1023
+ viewerEmail: user.email,
1024
+ });
1025
+ if (!detail) {
1026
+ throw new WorkflowValidationError(
1027
+ `Created change request #${prNumber} but could not fetch its detail`,
1028
+ );
1029
+ }
1030
+ this.events?.emit({
1031
+ kind: 'change-request-opened',
1032
+ number: prNumber,
1033
+ source: input.sourceBranch,
1034
+ target: input.targetBranch,
1035
+ authorIdHash: hashEmail(user.email),
1036
+ title: input.title,
1037
+ });
1038
+ return detail;
1039
+ }
1040
+
1041
+ /**
1042
+ * Refresh an existing change request from its target — re-runs the
1043
+ * `target → source` auto-merge so the CR diff reflects what the merge
1044
+ * would actually produce against the current target. Conflicts surface
1045
+ * as `ChangeRequestConflictsError`; the caller (UI or agent) is expected
1046
+ * to route into the resolution flow exactly as on open.
1047
+ *
1048
+ * Approvals on files touched by the merge resolution invalidate
1049
+ * automatically — they're pinned to the head SHA, and any new commit on
1050
+ * source drops the per-file approval gate (handled by the existing
1051
+ * approval staleness check). No special bookkeeping needed here.
1052
+ */
1053
+ async updateFromTarget(
1054
+ workspaceId: string,
1055
+ user: AuthUser,
1056
+ number: number,
1057
+ ): Promise<ChangeRequestDetail> {
1058
+ const detail = await this.prs.getPrDetail(number, { fresh: true, workspaceId, viewerEmail: user.email });
1059
+ if (!detail) {
1060
+ throw new WorkflowValidationError(
1061
+ `Change request #${number} not found.`,
1062
+ { kind: 'change-request-not-found', number },
1063
+ );
1064
+ }
1065
+ if (detail.state !== 'open') {
1066
+ throw new WorkflowValidationError(
1067
+ `Change request #${number} is ${detail.state} — only open requests can refresh from target.`,
1068
+ { kind: 'change-request-not-open', state: detail.state },
1069
+ );
1070
+ }
1071
+ const outcome = await this.git.mergeFromOrigin(
1072
+ workspaceId,
1073
+ detail.branch,
1074
+ detail.base,
1075
+ user,
1076
+ );
1077
+ if (outcome.kind === 'conflicts') {
1078
+ throw new ChangeRequestConflictsError(detail.branch, detail.base, outcome.paths);
1079
+ }
1080
+ // If a new merge commit landed, push so the CR picks it up. When
1081
+ // already up to date there's nothing to share — short-circuit the push.
1082
+ if (!outcome.alreadyUpToDate) {
1083
+ await this.git.push(workspaceId, user);
1084
+ }
1085
+ this.prs.invalidateDetailCache(number);
1086
+ const refreshed = await this.prs.getPrDetail(number, {
1087
+ fresh: true,
1088
+ workspaceId,
1089
+ viewerEmail: user.email,
1090
+ });
1091
+ if (!refreshed) {
1092
+ throw new WorkflowValidationError(
1093
+ `Refreshed change request #${number} but could not re-fetch its detail.`,
1094
+ );
1095
+ }
1096
+ return refreshed;
1097
+ }
1098
+
1099
+ listComments(number: number): Promise<ChangeRequestComment[]> {
1100
+ return this.reviewWorkflow.listComments(number);
1101
+ }
1102
+
1103
+ async postComment(
1104
+ number: number,
1105
+ user: AuthUser,
1106
+ input: PostChangeRequestCommentInput,
1107
+ headSha: string,
1108
+ ): Promise<ChangeRequestComment> {
1109
+ const comment = await this.reviewWorkflow.postComment(number, user, input, headSha);
1110
+ this.prs.invalidateDetailCache(number);
1111
+ return comment;
1112
+ }
1113
+
1114
+ async editComment(
1115
+ commentId: string,
1116
+ number: number,
1117
+ user: AuthUser,
1118
+ body: string,
1119
+ ): Promise<ChangeRequestComment> {
1120
+ const updated = await this.reviewWorkflow.editComment(commentId, number, user, body);
1121
+ this.prs.invalidateDetailCache(number);
1122
+ return updated;
1123
+ }
1124
+
1125
+ async deleteComment(commentId: string, number: number, user: AuthUser): Promise<void> {
1126
+ await this.reviewWorkflow.deleteComment(commentId, number, user);
1127
+ this.prs.invalidateDetailCache(number);
1128
+ }
1129
+
1130
+ async approveFile(
1131
+ number: number,
1132
+ path: string,
1133
+ user: AuthUser,
1134
+ files: ChangedFile[],
1135
+ headSha: string,
1136
+ baseBranch: string,
1137
+ authorIdHash: string | null,
1138
+ workspaceId: string,
1139
+ ): Promise<FileApproval[]> {
1140
+ const approvals = await this.reviewWorkflow.approveFile(
1141
+ number,
1142
+ path,
1143
+ user,
1144
+ files,
1145
+ headSha,
1146
+ baseBranch,
1147
+ authorIdHash,
1148
+ workspaceId,
1149
+ );
1150
+ this.prs.invalidateDetailCache(number);
1151
+ this.events?.emit({
1152
+ kind: 'approval-changed',
1153
+ number,
1154
+ path,
1155
+ approverUserId: user.id,
1156
+ change: 'added',
1157
+ });
1158
+ return approvals;
1159
+ }
1160
+
1161
+ async unapproveFile(
1162
+ number: number,
1163
+ path: string,
1164
+ user: AuthUser,
1165
+ files: ChangedFile[],
1166
+ headSha: string,
1167
+ baseBranch: string,
1168
+ authorIdHash: string | null,
1169
+ workspaceId: string,
1170
+ ): Promise<FileApproval[]> {
1171
+ const approvals = await this.reviewWorkflow.unapproveFile(
1172
+ number,
1173
+ path,
1174
+ user,
1175
+ files,
1176
+ headSha,
1177
+ baseBranch,
1178
+ authorIdHash,
1179
+ workspaceId,
1180
+ );
1181
+ this.prs.invalidateDetailCache(number);
1182
+ this.events?.emit({
1183
+ kind: 'approval-changed',
1184
+ number,
1185
+ path,
1186
+ approverUserId: user.id,
1187
+ change: 'removed',
1188
+ });
1189
+ return approvals;
1190
+ }
1191
+
1192
+ /**
1193
+ * Reject (close-without-merging) a change request. Per PLAN.md spec, the
1194
+ * authorized set is:
1195
+ * - the CR author (hash-matched against the body marker), OR
1196
+ * - any user with write permission to *every* changed file on the
1197
+ * target branch, OR
1198
+ * - an admin (write on `roles.yaml` at `origin/<target>`).
1199
+ *
1200
+ * The first and third are enforced by the underlying `cancelPr`. The
1201
+ * second is the workflow's broader rule; we check it here and, on
1202
+ * success, spoof `authorIdHash` to `hashEmail(user.email)` so `cancelPr`
1203
+ * accepts the call via its existing "viewer is author" branch. The
1204
+ * spoofing is safe because no caller outside this method can construct
1205
+ * an `authorIdHash` that matches its own email's hash — `cancelPr`
1206
+ * receives the hash via this internal call only.
1207
+ */
1208
+ async rejectChangeRequest(
1209
+ number: number,
1210
+ user: AuthUser,
1211
+ state: ChangeRequestState,
1212
+ authorIdHash: string | null,
1213
+ baseBranch: string,
1214
+ workspaceId: string,
1215
+ ): Promise<CancelChangeRequestResult> {
1216
+ let effectiveAuthorIdHash = authorIdHash;
1217
+ const callerHash = hashEmail(user.email);
1218
+ const callerIsAuthor = !!(authorIdHash && authorIdHash === callerHash);
1219
+
1220
+ if (!callerIsAuthor) {
1221
+ // Broader rule: write perm on every changed file at origin/<target>.
1222
+ // The path list comes from the PR detail — fetch fresh so the rule
1223
+ // applies against the current head (a stale list would let a user
1224
+ // reject a CR whose files they no longer all own).
1225
+ const detail = await this.prs.getPrDetail(number, {
1226
+ fresh: true,
1227
+ workspaceId,
1228
+ viewerEmail: user.email,
1229
+ });
1230
+ const paths = detail?.files.map((f) => f.path) ?? [];
1231
+ if (paths.length > 0) {
1232
+ const writeMap = await this.accessControl.canWriteBatchAtRef(
1233
+ workspaceId,
1234
+ `origin/${baseBranch}`,
1235
+ user.email,
1236
+ paths,
1237
+ );
1238
+ const writesAll = !!writeMap && paths.every((p) => writeMap.get(p) === true);
1239
+ if (writesAll) {
1240
+ // Spoof so the underlying `cancelPr` authorizes via its viewer-
1241
+ // is-author branch. The admin branch (write on roles.yaml) still
1242
+ // works alongside — `cancelPr` checks both.
1243
+ effectiveAuthorIdHash = callerHash;
1244
+ }
1245
+ }
1246
+ }
1247
+
1248
+ const result = await this.reviewWorkflow.cancelPr(
1249
+ number,
1250
+ user,
1251
+ state,
1252
+ effectiveAuthorIdHash,
1253
+ baseBranch,
1254
+ workspaceId,
1255
+ );
1256
+ this.prs.invalidateDetailCache(number);
1257
+ this.events?.emit({ kind: 'change-request-rejected', number });
1258
+ return result;
1259
+ }
1260
+
1261
+ /**
1262
+ * Merge a change request. The underlying `mergePr` does a local
1263
+ * `git merge --no-ff` + push; when it hits conflicts it throws
1264
+ * `ChangeRequestConflictsError`, which we translate into a structured
1265
+ * `conflicts-need-resolution` outcome (carrying the actual conflicting
1266
+ * paths) so the agent can resolve them on the source branch and retry.
1267
+ *
1268
+ * Hard-block and warning gates (PR state, approvals, admin bypass) are
1269
+ * still enforced by the underlying `mergePr` — this method just adds
1270
+ * the conflict short-circuit on top.
1271
+ */
1272
+ async mergeChangeRequest(
1273
+ number: number,
1274
+ user: AuthUser,
1275
+ headSha: string,
1276
+ approvals: FileApproval[],
1277
+ state: ChangeRequestState,
1278
+ title: string,
1279
+ baseBranch: string,
1280
+ workspaceId: string,
1281
+ opts?: { bypass?: boolean },
1282
+ ): Promise<MergeChangeRequestOutcome> {
1283
+ // Conflicts are surfaced by the local merge itself now (with the actual
1284
+ // conflicting paths) — no provider "mergeable" pre-check needed.
1285
+
1286
+ // roles.yaml is the file that decides admin membership, and only an admin
1287
+ // may edit it (enforced at lock-acquire on protected branches). Feature
1288
+ // branches are a free-for-all, so a non-admin CAN edit roles.yaml on their
1289
+ // own draft — and a CR merge is the one path that lands draft content on a
1290
+ // protected branch. To stop that becoming a privilege-escalation vector, we
1291
+ // never let a CR carry a roles.yaml change across the merge: if the CR's
1292
+ // roles.yaml differs from the base branch's, we restore the base version on
1293
+ // the source branch (commit + push) BEFORE merging, so the merged diff
1294
+ // carries no roles.yaml change. roles.yaml is mutable ONLY via the admin
1295
+ // Roles & Members surface (itself admin-gated). The rest of the CR merges
1296
+ // normally. Best-effort by design is NOT acceptable here — a failure to
1297
+ // neutralise must abort the merge, never fall through.
1298
+ const preserved = await this.preserveBaseRolesYaml(number, user, baseBranch);
1299
+
1300
+ // When preservation pushed a restore commit, the source-branch head advanced,
1301
+ // so the caller's `headSha`/`approvals` (resolved against the pre-preservation
1302
+ // head) are now stale — wrong for the merge log's `headShaAtMerge` and for the
1303
+ // approval-staleness the gate reads. Re-resolve from the live PR before merging.
1304
+ // Fail CLOSED: if the fresh detail can't be loaded we abort rather than fall
1305
+ // back to the stale inputs — merging on outdated gate inputs after a
1306
+ // security-sensitive rewrite is exactly what this path exists to prevent.
1307
+ let mergeHeadSha = headSha;
1308
+ let mergeApprovals = approvals;
1309
+ if (preserved) {
1310
+ const fresh = await this.getChangeRequestDetail(number, {
1311
+ fresh: true,
1312
+ workspaceId,
1313
+ viewerEmail: user.email,
1314
+ });
1315
+ if (!fresh) {
1316
+ throw new RolesYamlPreservationError(
1317
+ `could not reload change request #${number} after roles.yaml preservation; refusing to merge on stale gate inputs`,
1318
+ );
1319
+ }
1320
+ mergeHeadSha = fresh.headSha;
1321
+ mergeApprovals = fresh.approvals;
1322
+ }
1323
+
1324
+ let result;
1325
+ try {
1326
+ result = await this.reviewWorkflow.mergePr(
1327
+ number,
1328
+ user,
1329
+ mergeHeadSha,
1330
+ mergeApprovals,
1331
+ state,
1332
+ title,
1333
+ baseBranch,
1334
+ workspaceId,
1335
+ opts,
1336
+ );
1337
+ } catch (err) {
1338
+ // The local merge hit conflicts — hand the caller (agent/UI) the actual
1339
+ // conflicting paths so they can resolve on the source branch and retry.
1340
+ if (err instanceof ChangeRequestConflictsError) {
1341
+ return { kind: 'conflicts-need-resolution', conflictedPaths: err.conflictedPaths };
1342
+ }
1343
+ throw err;
1344
+ }
1345
+ // The merge landed on `origin/<baseBranch>`. Pull the
1346
+ // TARGET branch's own workspace so its working tree doesn't fall behind the
1347
+ // remote — the file tools serve the working tree (not origin), so without
1348
+ // this a read right after a merge misses the just-merged change. Best-effort:
1349
+ // the merge already succeeded on origin, so a pull hiccup must not fail the
1350
+ // response (a later fetch/pull reconciles).
1351
+ try {
1352
+ const targetWorkspace = await this.workspaceService.getOrCreateForBranch(baseBranch);
1353
+ await this.git.pull(targetWorkspace.id);
1354
+ } catch (err) {
1355
+ console.warn(
1356
+ `[merge] post-merge pull of target "${baseBranch}" failed — its workspace may be momentarily behind origin`,
1357
+ err,
1358
+ );
1359
+ }
1360
+ this.prs.invalidateDetailCache(number);
1361
+ this.events?.emit({ kind: 'change-request-merged', number });
1362
+ return { kind: 'merged', result };
1363
+ }
1364
+
1365
+ /**
1366
+ * Guarantee a CR carries NO `roles.yaml` change across its merge: if the CR's
1367
+ * `roles.yaml` differs from the base branch's, restore the base version on the
1368
+ * SOURCE branch (commit + push) so the merged diff has no roles.yaml change.
1369
+ * No-op when the CR doesn't touch roles.yaml. Always resolves identity from
1370
+ * `origin/<base>` and `origin/<head>` (the published state) — never the local
1371
+ * working tree, which a caller could have dirtied.
1372
+ *
1373
+ * Fail-CLOSED: any error restoring the base version throws
1374
+ * {@link RolesYamlPreservationError}, aborting the merge. We must never let a
1375
+ * roles.yaml change slip through because the neutralisation step failed.
1376
+ */
1377
+ private async preserveBaseRolesYaml(
1378
+ number: number,
1379
+ user: AuthUser,
1380
+ baseBranch: string,
1381
+ ): Promise<boolean> {
1382
+ const ROLES_YAML = 'roles.yaml';
1383
+ let headBranch: string;
1384
+ try {
1385
+ const summary = await this.prs.getPr(number);
1386
+ if (!summary) throw new Error(`change request #${number} not found`);
1387
+ // Use the PR's own authoritative base rather than trusting the caller's
1388
+ // baseBranch arg throughout — cross-check to catch stale/mismatched inputs.
1389
+ if (summary.base !== baseBranch)
1390
+ throw new Error(
1391
+ `change request #${number} base mismatch: expected "${baseBranch}", PR reports "${summary.base}"`,
1392
+ );
1393
+ headBranch = summary.branch;
1394
+ // A CR into a protected branch is the only escalation vector we're closing;
1395
+ // base is always a protected branch here, head is always a feature branch.
1396
+ // Guard against a degenerate self-targeting CR (head === base) just in case.
1397
+ if (!headBranch || headBranch === summary.base) return false;
1398
+
1399
+ // Work in the SOURCE branch's own per-branch workspace (already checked out
1400
+ // on that branch). Fetch so we read the CR's TRUE published roles.yaml from
1401
+ // the origin refs below (the comparison never touches the working tree) and
1402
+ // so a restoring commit fast-forwards on push. We deliberately DON'T
1403
+ // `resetToRemote` here: that workspace is shared per-branch and can carry
1404
+ // disk-first edits whose commits are still queued (see PendingCommitsService),
1405
+ // and a hard reset would silently discard them.
1406
+ const ws = await this.workspaceService.getOrCreateForBranch(headBranch);
1407
+ await this.git.fetch(ws.id);
1408
+
1409
+ const baseRoles = await this.git.readFileAtRef(ws.id, `origin/${summary.base}`, ROLES_YAML);
1410
+ const headRoles = await this.git.readFileAtRef(ws.id, `origin/${headBranch}`, ROLES_YAML);
1411
+
1412
+ // Identical (incl. both-absent) → the CR doesn't change roles.yaml. Done.
1413
+ if (baseRoles === headRoles) return false;
1414
+
1415
+ // The restore below writes roles.yaml directly to the shared per-branch
1416
+ // working tree. `LockingFilesystem` serializes every OTHER roles.yaml edit
1417
+ // (human save button, agent write) through the SAME per-file lock, keyed on
1418
+ // the workspace-relative path (`<kbDirName>/roles.yaml`) — see
1419
+ // `access.routes.ts` (`wsEditPath`) and `LockingFilesystem.withLock`. Take
1420
+ // that lock here too, or a concurrent same-branch editor can overwrite our
1421
+ // restore between the write and the commit (or slip a fresh roles.yaml
1422
+ // change past the neutralisation). Contended → the editor holds it → fail
1423
+ // CLOSED: abort the merge rather than race. TTL-expiry on abandoned locks
1424
+ // means a stale holder can't wedge merges forever.
1425
+ const rolesLockPath = `${this.kbDirName}/${ROLES_YAML}`;
1426
+ const lock = await this.fileLocks.acquire(ws.id, headBranch, rolesLockPath, user);
1427
+ if (!lock.acquired) {
1428
+ throw new Error(
1429
+ `roles.yaml is being edited on "${headBranch}" (locked by ${lock.lock.holderName}); refusing to merge until the edit settles`,
1430
+ );
1431
+ }
1432
+
1433
+ try {
1434
+ // Restore the base version on the source branch so the change disappears
1435
+ // from the merge diff. (If base has no roles.yaml at all — baseRoles ===
1436
+ // null — restore by deleting the file.)
1437
+ const repoDir = path.join(await this.workspaceService.getWorkspacePath(ws.id), this.kbDirName);
1438
+ const abs = path.join(repoDir, ROLES_YAML);
1439
+ if (baseRoles === null) {
1440
+ await fs.rm(abs, { force: true });
1441
+ } else {
1442
+ await fs.writeFile(abs, baseRoles, 'utf-8');
1443
+ }
1444
+
1445
+ // Commit ONLY roles.yaml via commitFile (`git add -- roles.yaml`), never
1446
+ // `commitChanges` (`git add -A`): on this shared per-branch workspace other
1447
+ // files may be dirty from a concurrent same-branch save, and `add -A` would
1448
+ // sweep those unrelated edits into our "preserve roles.yaml" commit under the
1449
+ // wrong author/message. Feature branch → the protected-branch gate doesn't
1450
+ // fire, so this commits cleanly. Then publish so the merge sees the
1451
+ // neutralised source. A non-fast-forward push (the source advanced under us)
1452
+ // throws → RolesYamlPreservationError → the merge aborts, fail-closed.
1453
+ const committed = await this.git.commitFile(
1454
+ ws.id,
1455
+ user,
1456
+ ROLES_YAML,
1457
+ 'Preserve official roles.yaml (not editable via change request)',
1458
+ true, // skipValidator — this restores an already-validated base version
1459
+ );
1460
+
1461
+ // We are past the `baseRoles === headRoles` check, so origin/<head> and
1462
+ // origin/<base> DO differ — a restore commit is REQUIRED to protect the
1463
+ // remote. If commitFile produced nothing (the local working tree already
1464
+ // matched the base version, e.g. the per-branch checkout is out of sync
1465
+ // with origin/<head>), then origin/<head> STILL carries the divergent
1466
+ // roles.yaml and the merge would land it. Treat that as a hard failure —
1467
+ // returning false here would fail OPEN. Fail closed instead.
1468
+ if (!committed) {
1469
+ throw new Error(
1470
+ 'roles.yaml restore produced no commit while origin still diverges from base — source workspace out of sync; refusing to merge',
1471
+ );
1472
+ }
1473
+ await this.git.push(ws.id, user);
1474
+ this.accessControl.invalidate(ws.id);
1475
+ return true;
1476
+ } finally {
1477
+ // We already committed inline, so drop the lock row directly rather than
1478
+ // going through `releaseLock` (which would enqueue a duplicate commit).
1479
+ await this.fileLocks.release(ws.id, headBranch, rolesLockPath, user);
1480
+ }
1481
+ } catch (err) {
1482
+ const detail = err instanceof Error ? err.message : String(err);
1483
+ // Log the raw git/push detail server-side; the thrown error keeps it OFF
1484
+ // the client-facing 502 message (see RolesYamlPreservationError).
1485
+ console.warn(`[merge] roles.yaml preservation failed for #${number}:`, detail);
1486
+ throw new RolesYamlPreservationError(detail);
1487
+ }
1488
+ }
1489
+
1490
+ /**
1491
+ * Names of files changed between the target branch (on origin) and the
1492
+ * source (HEAD) — the diff the CR will surface. Three-dot range so
1493
+ * commits that exist only on origin/target but not on source don't
1494
+ * inflate the list. Failures fall back to an empty list — a missing
1495
+ * affected-owners block is cosmetic, not a reason to abort CR creation.
1496
+ */
1497
+ private async listChangedPathsBetweenBranches(
1498
+ cwd: string,
1499
+ targetBranch: string,
1500
+ sourceBranch: string,
1501
+ ): Promise<string[]> {
1502
+ try {
1503
+ const { stdout } = await execFileAsync(
1504
+ 'git',
1505
+ ['-C', cwd, 'diff', '--name-only', `origin/${targetBranch}...${sourceBranch}`],
1506
+ { env: { ...process.env }, maxBuffer: 4 * 1024 * 1024 },
1507
+ );
1508
+ return stdout.split('\n').map((s) => s.trim()).filter(Boolean);
1509
+ } catch (err) {
1510
+ console.warn(
1511
+ '[workflow] listChangedPathsBetweenBranches failed:',
1512
+ redactTokens(err instanceof Error ? err.message : String(err)),
1513
+ );
1514
+ return [];
1515
+ }
1516
+ }
1517
+
1518
+ /**
1519
+ * Build the `## Affected owners` markdown block that gets appended to
1520
+ * the CR body. Lists the eligible approvers (roles + named users) per
1521
+ * changed path, per the access tree on `origin/<targetBranch>` — the same
1522
+ * tree the approval gate uses server-side, so what the body advertises
1523
+ * matches who can actually approve at merge time. The per-path list is
1524
+ * capped (see {@link formatAffectedOwnersBlock}) so the body stays within
1525
+ * the `gh pr create --body` arg limit on large changesets.
1526
+ *
1527
+ * Returns the empty string when there are no touched paths or no
1528
+ * resolvable eligibility — keeps the body clean rather than printing
1529
+ * a stub heading.
1530
+ */
1531
+ private async buildAffectedOwnersBlock(
1532
+ workspaceId: string,
1533
+ targetBranch: string,
1534
+ paths: string[],
1535
+ ): Promise<string> {
1536
+ if (paths.length === 0) return '';
1537
+ let resolved: Awaited<ReturnType<IAccessControl['eligibleWritersForPathsAtRef']>> = null;
1538
+ try {
1539
+ resolved = await this.accessControl.eligibleWritersForPathsAtRef(
1540
+ workspaceId,
1541
+ `origin/${targetBranch}`,
1542
+ paths,
1543
+ );
1544
+ } catch (err) {
1545
+ console.warn(
1546
+ '[workflow] eligibleWritersForPathsAtRef failed:',
1547
+ err instanceof Error ? err.message : String(err),
1548
+ );
1549
+ }
1550
+ if (!resolved || resolved.size === 0) return '';
1551
+
1552
+ return formatAffectedOwnersBlock(paths, resolved);
1553
+ }
1554
+
1555
+ }