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