@bevel-software/platform-core-backend 0.22.0 → 0.23.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 (325) hide show
  1. package/dist/core/core-ports.d.ts +11 -1
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts +3 -3
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +30 -11
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +9 -2
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +112 -23
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +34 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +89 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +0 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +11 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +42 -12
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +67 -10
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +214 -35
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/access/access.routes.d.ts.map +1 -1
  31. package/dist/modules/access/access.routes.js +13 -15
  32. package/dist/modules/access/access.routes.js.map +1 -1
  33. package/dist/modules/auth/account-admission.d.ts +58 -7
  34. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  35. package/dist/modules/auth/account-admission.js +44 -1
  36. package/dist/modules/auth/account-admission.js.map +1 -1
  37. package/dist/modules/auth/account.routes.d.ts +1 -1
  38. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  39. package/dist/modules/auth/account.routes.js +61 -5
  40. package/dist/modules/auth/account.routes.js.map +1 -1
  41. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  42. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.middleware.js +18 -7
  44. package/dist/modules/auth/auth.middleware.js.map +1 -1
  45. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  46. package/dist/modules/auth/auth.routes.js +8 -0
  47. package/dist/modules/auth/auth.routes.js.map +1 -1
  48. package/dist/modules/auth/auth.service.d.ts +70 -2
  49. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  50. package/dist/modules/auth/auth.service.js +176 -7
  51. package/dist/modules/auth/auth.service.js.map +1 -1
  52. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  53. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  54. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  55. package/dist/modules/database/core-schema.d.ts +41 -24
  56. package/dist/modules/database/core-schema.d.ts.map +1 -1
  57. package/dist/modules/database/core-schema.js +35 -27
  58. package/dist/modules/database/core-schema.js.map +1 -1
  59. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  60. package/dist/modules/kb-fs/branch-name.js +12 -2
  61. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  62. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  63. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  64. package/dist/modules/kb-fs/repo-path.js +79 -0
  65. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  66. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  67. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  68. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  69. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  70. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  71. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  72. package/dist/modules/kb-sync/sync-auth.js +1 -1
  73. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  74. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  75. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  76. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  77. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  78. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  79. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  80. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  81. package/dist/modules/settings/deployment-settings.service.js +8 -3
  82. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  83. package/dist/modules/settings/setup.routes.d.ts +52 -1
  84. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  85. package/dist/modules/settings/setup.routes.js +207 -19
  86. package/dist/modules/settings/setup.routes.js.map +1 -1
  87. package/dist/modules/skills/skills.contract.d.ts +90 -18
  88. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  89. package/dist/modules/skills/skills.contract.js +4 -1
  90. package/dist/modules/skills/skills.contract.js.map +1 -1
  91. package/dist/modules/skills/skills.service.d.ts +98 -9
  92. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  93. package/dist/modules/skills/skills.service.js +239 -37
  94. package/dist/modules/skills/skills.service.js.map +1 -1
  95. package/dist/modules/skills/skills.tools.d.ts +7 -0
  96. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  97. package/dist/modules/skills/skills.tools.js +71 -7
  98. package/dist/modules/skills/skills.tools.js.map +1 -1
  99. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  100. package/dist/modules/tool-auth/external-api-key.service.js +4 -2
  101. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  102. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  103. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  104. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  105. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  106. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  107. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  108. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  109. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  110. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  111. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  112. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  113. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  114. package/dist/modules/tool-helpers/validate-token.js +1 -1
  115. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  116. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  117. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  118. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  119. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  120. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  121. package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
  122. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  123. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  124. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  125. package/dist/modules/workflow/file-lock.service.js +30 -0
  126. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  127. package/dist/modules/workflow/git/git.service.d.ts +69 -0
  128. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  129. package/dist/modules/workflow/git/git.service.js +153 -2
  130. package/dist/modules/workflow/git/git.service.js.map +1 -1
  131. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  132. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  133. package/dist/modules/workflow/pending-commits.service.js +55 -0
  134. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  135. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  136. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  137. package/dist/modules/workflow/workflow-hooks.js +16 -1
  138. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  139. package/dist/modules/workflow/workflow.service.d.ts +60 -6
  140. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  141. package/dist/modules/workflow/workflow.service.js +111 -4
  142. package/dist/modules/workflow/workflow.service.js.map +1 -1
  143. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  144. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  145. package/dist/modules/workspace/agent-access.gate.js +123 -0
  146. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  147. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  148. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  149. package/dist/modules/workspace/routine-write-policy.js +5 -6
  150. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  151. package/dist/modules/workspace/session-sink.d.ts +5 -5
  152. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  153. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  154. package/dist/modules/workspace/set-aside-clone.js +92 -0
  155. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  156. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
  157. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  158. package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
  159. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  160. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  161. package/dist/modules/workspace/workspace.routes.js +92 -3
  162. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  163. package/dist/modules/workspace/workspace.service.d.ts +115 -5
  164. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  165. package/dist/modules/workspace/workspace.service.js +255 -25
  166. package/dist/modules/workspace/workspace.service.js.map +1 -1
  167. package/dist/modules/workspace/workspace.tools.d.ts +2 -2
  168. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  169. package/dist/modules/workspace/workspace.tools.js +309 -115
  170. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  171. package/dist/modules/write-access/write-access.d.ts +60 -0
  172. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  173. package/dist/modules/write-access/write-access.js +129 -0
  174. package/dist/modules/write-access/write-access.js.map +1 -0
  175. package/dist/shared/domain-errors.d.ts +25 -0
  176. package/dist/shared/domain-errors.d.ts.map +1 -1
  177. package/dist/shared/domain-errors.js +28 -0
  178. package/dist/shared/domain-errors.js.map +1 -1
  179. package/dist/shared/git.contract.d.ts +20 -0
  180. package/dist/shared/git.contract.d.ts.map +1 -1
  181. package/dist/shared/git.contract.js +26 -0
  182. package/dist/shared/git.contract.js.map +1 -1
  183. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  184. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  185. package/dist/tenancy/static-tenant-source.js +0 -2
  186. package/dist/tenancy/static-tenant-source.js.map +1 -1
  187. package/migrations/0014_change_request_closed_reason.sql +1 -0
  188. package/migrations/0015_account_deactivation.sql +3 -0
  189. package/migrations/meta/0014_snapshot.json +2265 -0
  190. package/migrations/meta/0015_snapshot.json +2271 -0
  191. package/migrations/meta/_journal.json +14 -0
  192. package/package.json +3 -3
  193. package/src/__tests__/kb-layout-config.test.ts +0 -2
  194. package/src/__tests__/retired-settings.test.ts +96 -0
  195. package/src/core/__tests__/gated-boot.test.ts +132 -0
  196. package/src/core/__tests__/lifecycle.test.ts +170 -1
  197. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  198. package/src/core/core-ports.ts +11 -1
  199. package/src/core/create-core-server.ts +32 -14
  200. package/src/core/create-core-services.ts +129 -23
  201. package/src/core/lifecycle.ts +98 -13
  202. package/src/core-config.ts +13 -14
  203. package/src/index.ts +12 -1
  204. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  205. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  206. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  207. package/src/modules/access/access-control.interface.ts +43 -12
  208. package/src/modules/access/access-control.service.ts +234 -36
  209. package/src/modules/access/access.routes.ts +13 -15
  210. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
  211. package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
  212. package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
  213. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  214. package/src/modules/auth/__tests__/auth.service.test.ts +1 -1
  215. package/src/modules/auth/account-admission.ts +78 -9
  216. package/src/modules/auth/account.routes.ts +63 -7
  217. package/src/modules/auth/auth.middleware.ts +19 -8
  218. package/src/modules/auth/auth.routes.ts +8 -0
  219. package/src/modules/auth/auth.service.ts +179 -6
  220. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  221. package/src/modules/database/core-schema.ts +35 -27
  222. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  223. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  224. package/src/modules/kb-fs/branch-name.ts +14 -1
  225. package/src/modules/kb-fs/repo-path.ts +85 -0
  226. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  227. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  228. package/src/modules/kb-sync/sync-auth.ts +2 -2
  229. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  230. package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
  231. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  232. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  233. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  234. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  235. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
  236. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
  237. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
  238. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  239. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  240. package/src/modules/settings/deployment-settings.service.ts +8 -3
  241. package/src/modules/settings/setup.routes.ts +263 -19
  242. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  243. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  244. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  245. package/src/modules/skills/skills.contract.ts +91 -18
  246. package/src/modules/skills/skills.service.ts +278 -41
  247. package/src/modules/skills/skills.tools.ts +80 -8
  248. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  249. package/src/modules/tool-auth/external-api-key.service.ts +4 -2
  250. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  251. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  252. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  253. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  254. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  255. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  256. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  257. package/src/modules/tool-helpers/validate-token.ts +1 -1
  258. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  259. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  260. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  261. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
  262. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  263. package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
  264. package/src/modules/workflow/file-lock.service.ts +31 -0
  265. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  266. package/src/modules/workflow/git/git.service.ts +168 -0
  267. package/src/modules/workflow/pending-commits.service.ts +59 -1
  268. package/src/modules/workflow/workflow-hooks.ts +64 -26
  269. package/src/modules/workflow/workflow.service.ts +121 -4
  270. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  271. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
  272. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  273. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  274. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  275. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  276. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  277. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  278. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
  279. package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
  280. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
  281. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
  282. package/src/modules/workspace/__tests__/workspace.tools.test.ts +685 -14
  283. package/src/modules/workspace/agent-access.gate.ts +164 -0
  284. package/src/modules/workspace/routine-write-policy.ts +5 -6
  285. package/src/modules/workspace/session-sink.ts +5 -5
  286. package/src/modules/workspace/set-aside-clone.ts +96 -0
  287. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
  288. package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
  289. package/src/modules/workspace/workspace.routes.ts +95 -3
  290. package/src/modules/workspace/workspace.service.ts +256 -24
  291. package/src/modules/workspace/workspace.tools.ts +338 -119
  292. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  293. package/src/modules/write-access/write-access.ts +153 -0
  294. package/src/shared/domain-errors.ts +31 -0
  295. package/src/shared/git.contract.ts +27 -0
  296. package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
  297. package/src/tenancy/static-tenant-source.ts +0 -3
  298. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  299. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  300. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  301. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  302. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  303. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  304. package/dist/modules/workflow/session-ontology.service.js +0 -147
  305. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  306. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  307. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  308. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  309. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  310. package/dist/shared/kb-layout.d.ts +0 -39
  311. package/dist/shared/kb-layout.d.ts.map +0 -1
  312. package/dist/shared/kb-layout.js +0 -103
  313. package/dist/shared/kb-layout.js.map +0 -1
  314. package/dist/shared/kb-layout.test.d.ts +0 -2
  315. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  316. package/dist/shared/kb-layout.test.js +0 -75
  317. package/dist/shared/kb-layout.test.js.map +0 -1
  318. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  319. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  320. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  321. package/src/modules/workflow/session-ontology.service.ts +0 -183
  322. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  323. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  324. package/src/shared/kb-layout.test.ts +0 -98
  325. package/src/shared/kb-layout.ts +0 -102
@@ -739,7 +739,7 @@ export function createWorkspaceRoutes(
739
739
  }
740
740
 
741
741
  /**
742
- * GET /workspace/:id/file/raw?path=<file>[&download=1][&v=<n>]
742
+ * GET /workspace/:id/file/raw?path=<file>[&download=1][&v=<n>][&ref=<sha>[&side=before]]
743
743
  *
744
744
  * The bytes of one workspace file: for the document renderers' fetches, the
745
745
  * file tree's Download, and the `<img>` tags the markdown pipeline emits for
@@ -750,7 +750,8 @@ export function createWorkspaceRoutes(
750
750
  * request ──▶ auth: Bearer, else the cookie
751
751
  * ──▶ read gate on the path 403 if the caller may not read it
752
752
  * ──▶ download gate, if ?download=1 403 without the download: verb
753
- * ──▶ readFileBinary 404 missing, 403 traversal
753
+ * ──▶ readFileBinary, or the bytes at 404 missing, 403 traversal
754
+ * ?ref when one is named
754
755
  * ──▶ Content-Type from the extension, nosniff, a CSP sandbox for
755
756
  * inline svg, attachment disposition for a download, and
756
757
  * Cache-Control: private, no-cache
@@ -760,6 +761,30 @@ export function createWorkspaceRoutes(
760
761
  *
761
762
  * `?v=` is not read here. The frontend bumps it when it learns an image
762
763
  * changed, so the browser asks for a URL it has not cached.
764
+ *
765
+ * `?ref=<sha>` serves the file AS IT WAS at that save instead of the working
766
+ * tree, so Version history can mount the very viewers this route already
767
+ * feeds. `&side=before` reads `<sha>^` — the version just before a save,
768
+ * which is the only thing the save that DELETED a file can show. Everything
769
+ * around the read is deliberately unchanged: the same read gate, the same
770
+ * download verb, the same content type, the same svg sandbox. What the ref
771
+ * changes is WHICH bytes, never WHO may have them:
772
+ *
773
+ * - both gates resolve against TODAY's tree, not the rules as they were at
774
+ * that save (hx-history-file-preview, decision 3): anyone who may read
775
+ * the file now may read any of its past saves, and anyone who may not is
776
+ * refused with the same sentence they get for the file itself;
777
+ * - the save must be in the history of the branch this workspace has
778
+ * checked out — `fileBytesAtChange` refuses anything else with a 404
779
+ * carrying `VERSION_NOT_ON_BRANCH_MESSAGE`;
780
+ * - a ref that is present but malformed is a 400, never a silent fall back
781
+ * to `readFileBinary`. Serving today's bytes for a request that asked for
782
+ * a past version would be the worst possible answer — it looks like a
783
+ * success — so the ref branch and the working-tree branch are exclusive;
784
+ * - and a read that fails for OUR reasons (a blob over the git runner's
785
+ * output ceiling, a timed-out `cat-file`) is a logged 500, not the
786
+ * blanket 404 the working-tree read answers with: "not found" about a
787
+ * save the reader can see listed is a lie about their own history.
763
788
  */
764
789
  router.get('/workspace/:id/file/raw', async (req, res) => {
765
790
  const id = authenticated(req, res);
@@ -771,6 +796,27 @@ export function createWorkspaceRoutes(
771
796
  }
772
797
  const filePath = inRepo(res, requested);
773
798
  if (filePath === null) return;
799
+ // A past save, asked for by sha. Validated BEFORE the gates so a
800
+ // mistyped ref is a 400 rather than a 200 carrying today's bytes.
801
+ const rawRef = req.query.ref;
802
+ if (rawRef !== undefined && (typeof rawRef !== 'string' || !/^[a-f0-9]{7,40}$/i.test(rawRef))) {
803
+ res.status(400).json({ error: 'ref must be a commit sha' });
804
+ return;
805
+ }
806
+ const ref = typeof rawRef === 'string' ? rawRef : null;
807
+ // Only the two sides exist; `side` without `ref` names nothing, and an
808
+ // unrecognised value is a spelling mistake in a security-relevant
809
+ // parameter, not a default to guess at.
810
+ const rawSide = req.query.side;
811
+ if (rawSide !== undefined && rawSide !== 'before' && rawSide !== 'after') {
812
+ res.status(400).json({ error: "side must be 'before' or 'after'" });
813
+ return;
814
+ }
815
+ if (rawSide !== undefined && ref === null) {
816
+ res.status(400).json({ error: 'side requires ref' });
817
+ return;
818
+ }
819
+ const side = rawSide === 'before' ? 'before' : 'after';
774
820
  // `?download=1` flips this from inline-serve (used by PdfRenderer and
775
821
  // the image renderers) to "save to disk" — and the save path is gated
776
822
  // on per-path `download:` rules in access.md. The inline path stays
@@ -785,7 +831,28 @@ export function createWorkspaceRoutes(
785
831
  if (!(await requireDownloadPermission(req, res, id, filePath))) return;
786
832
  }
787
833
  try {
788
- const buffer = await workspaceService.readFileBinary(id, filePath);
834
+ let buffer: Buffer;
835
+ if (ref === null) {
836
+ buffer = await workspaceService.readFileBinary(id, filePath);
837
+ } else {
838
+ const at = await workflowService.fileBytesAtChange(id, filePath, ref, side);
839
+ // Nothing at that path on that side of the save — for `before`, also
840
+ // how a root commit answers, since it has no parent to read.
841
+ if (at === null) {
842
+ res.status(404).json({ error: 'File not found' });
843
+ return;
844
+ }
845
+ // `IWorkflowService` is isomorphic, so the bytes arrive typed as a
846
+ // `Uint8Array`. `res.send` recognises a Buffer and JSON-encodes
847
+ // anything else, so a zero-copy view over the same memory is what it
848
+ // has to be handed — never a copy of a file that may be tens of MB.
849
+ buffer = Buffer.from(at.bytes.buffer, at.bytes.byteOffset, at.bytes.byteLength);
850
+ // The blob's object id IS the content hash, and a past version can
851
+ // never change, so this is the strongest ETag available and costs no
852
+ // second pass over the bytes. Set BEFORE `res.send`, which then skips
853
+ // its own weak ETag and answers a matching If-None-Match with a 304.
854
+ res.setHeader('ETag', `"${at.blobId}"`);
855
+ }
789
856
  const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
790
857
  const mimeTypes: Record<string, string> = {
791
858
  '.png': 'image/png',
@@ -844,6 +911,31 @@ export function createWorkspaceRoutes(
844
911
  sendError(res, error);
845
912
  return;
846
913
  }
914
+ // A refusal the ref branch raised has to reach the caller AS ITSELF: a
915
+ // save from another branch is answered with
916
+ // `VERSION_NOT_ON_BRANCH_MESSAGE`, and flattening it into the generic
917
+ // "File not found" would tell a reader their file had vanished. Only
918
+ // the ref branch can raise one, so the working-tree read keeps the
919
+ // blanket 404 it has always answered with.
920
+ if (ref !== null && error instanceof WorkflowDomainError) {
921
+ sendError(res, error);
922
+ return;
923
+ }
924
+ // Anything else out of the ref branch is OUR failure, not a missing
925
+ // version: the read goes through the git runner, where a blob over
926
+ // `MAX_OUTPUT_BYTES` and a timed-out `cat-file` both arrive here as
927
+ // plain errors. A 404 would tell a reader the save they can see listed
928
+ // had vanished, and would do it silently — so it is a logged 500, and
929
+ // "Try again" in the pane is then a truthful offer.
930
+ if (ref !== null) {
931
+ log.error(
932
+ `could not read ${printable(filePath)} at ${printable(ref)}: ${printable(
933
+ error instanceof Error ? error.message : String(error),
934
+ )}`,
935
+ );
936
+ res.status(500).json({ error: 'Could not read this version of the file' });
937
+ return;
938
+ }
847
939
  res.status(404).json({ error: 'File not found' });
848
940
  }
849
941
  });
@@ -41,6 +41,9 @@ import {
41
41
  isMissingRemoteBranchFailure,
42
42
  } from '../../shared/domain-errors.js';
43
43
  import { assertValidBranchName } from '../kb-fs/branch-name.js';
44
+ import { normalizeRepositoryAddress, sameRepository } from '../kb-fs/remote-url.js';
45
+ import { redactSecret, urlQuerySecrets } from '../../shared/redact-secret.js';
46
+ import { setAsideClone, setAsideRootFor, setAsideStamp } from './set-aside-clone.js';
44
47
  import {
45
48
  cloneCredentialArgs,
46
49
  cloneCredentialConfigArgs,
@@ -307,10 +310,40 @@ export class WorkspaceService implements IWorkspaceService {
307
310
  * constructed service.
308
311
  */
309
312
  private readonly gitRunner: IGitRunner = new NodeGitRunner(),
313
+ /**
314
+ * Where a working copy of a repository that is no longer the configured
315
+ * one is kept. THE SAME VALUE THE KB STARTUP PHASE IS GIVEN — what the two
316
+ * of them move must end up in one place, and omitting it here falls back to
317
+ * a directory that does not survive a deployment's next recreate.
318
+ *
319
+ * Why, in one place: `setAsideRootFor` in `set-aside-clone.ts`.
320
+ */
321
+ private readonly setAsideRoot?: string,
322
+ /**
323
+ * What surrounds the setting aside of ONE working copy when a branch is
324
+ * opened and its clone turns out to be of another repository: given the
325
+ * workspace id and the move, it runs the move. The composition root
326
+ * passes what the startup phase does around its own set-asides: the
327
+ * commit worker held still, the copy's queued commits held back for a
328
+ * person, the locks on its branch dropped. If it throws, the copy stays
329
+ * where it was and the branch is refused with that reason. Absent, the
330
+ * move runs as it is.
331
+ */
332
+ private readonly aroundSetAside: (workspaceId: string, move: () => Promise<void>) => Promise<void> = (
333
+ _workspaceId,
334
+ move,
335
+ ) => move(),
310
336
  ) {
311
337
  this.kbRepoUrl = typeof kbRepoUrl === 'function' ? kbRepoUrl : () => kbRepoUrl;
312
338
  }
313
339
 
340
+ /**
341
+ * The set-asides in flight, one per branch. Two callers opening the same
342
+ * branch both find the clone of another repository; the second waits for
343
+ * the first's move instead of trying to move a directory that has gone.
344
+ */
345
+ private readonly inFlightSetAsides = new Map<string, Promise<void>>();
346
+
314
347
  private get kbDirName(): string {
315
348
  return this.kb.kbDirName;
316
349
  }
@@ -617,22 +650,93 @@ export class WorkspaceService implements IWorkspaceService {
617
650
 
618
651
  // Check disk — the clone may have been bootstrapped by a previous
619
652
  // process and survived a restart.
620
- try {
621
- await fs.access(path.join(repoDir, '.git'));
622
- // Migration for clones already on disk: re-stamp the tracking config the
623
- // first time this process opens them, so a clone whose config drifted
624
- // (duplicate fetch refspec / merge ref) is repaired before anything
625
- // pulls it. Once per branch per process — the cached paths above return
626
- // before reaching here.
627
- await this.normalizeCloneConfig(repoDir, branch);
628
- this.registerBranchDir(branch, workspaceDir);
629
- return this.buildWorkspaceInfo(branch, workspaceDir);
630
- } catch {
631
- // Not on disk — bootstrap below.
653
+ const onDisk = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
654
+ // A clone fetches and pushes through the address stored in its OWN
655
+ // `remote.origin.url`, which nothing rewrites when the configured
656
+ // repository changes. Adopting one of the repository that was replaced is
657
+ // how every later fetch answers "repository not found" — so it is set
658
+ // aside here and cloned fresh below, the same rule, and the same
659
+ // destination, as the KB startup phase applies to the whole workspaces
660
+ // root.
661
+ //
662
+ // SET ASIDE, never deleted. The phase sweeps only AFTER it has reached the
663
+ // configured remote, so that a typo in the address cannot cost anyone
664
+ // their work. This path has reached nothing: it is a branch being opened,
665
+ // and it runs in exactly the states where the address is stored but
666
+ // unproven — a gated boot, a break-glass start, a save that changed the
667
+ // address before the phase got to it. Deleting on a string difference
668
+ // there would throw away commits that exist nowhere else because somebody
669
+ // mistyped a host name.
670
+ //
671
+ // OUTSIDE the adoption `try` below, deliberately: a clone that cannot be
672
+ // moved must fail this call with that reason. Swallowed into "bootstrap
673
+ // below", it would be found again by the clone step, which sees a
674
+ // directory with a `.git` in it, calls that already-cloned, and hands back
675
+ // the very working copy this refused to adopt.
676
+ // Counted before the disk is looked at: the startup phase may take this
677
+ // very copy away while the checks below are awaited (`forgetClone`), and a
678
+ // copy that went must not be registered as the branch's.
679
+ const discardsAtAdoption = this.discardsOf(id);
680
+ const setAsideInFlight = this.inFlightSetAsides.get(branch);
681
+ if (setAsideInFlight) {
682
+ // Someone else is moving it. Wait, then clone below; a move that failed
683
+ // refuses this caller with the same reason.
684
+ await setAsideInFlight;
685
+ } else if (onDisk && (await this.isCloneOfAnotherRepository(repoDir))) {
686
+ // Asked again: another caller may have started the move while the
687
+ // address was being read.
688
+ const startedMeanwhile = this.inFlightSetAsides.get(branch);
689
+ if (startedMeanwhile) {
690
+ await startedMeanwhile;
691
+ } else {
692
+ const kept = path.join(setAsideRootFor(this.workspacesRoot, this.setAsideRoot), setAsideStamp(), id);
693
+ // What surrounds the move may wait, and the startup phase may set
694
+ // this very copy aside meanwhile. Then there is nothing left to move:
695
+ // the phase did it, and said so.
696
+ let moved = false;
697
+ const moving = this.aroundSetAside(id, async () => {
698
+ if (this.discardsOf(id) !== discardsAtAdoption) return;
699
+ await setAsideClone(repoDir, kept);
700
+ moved = true;
701
+ }).finally(() => {
702
+ this.inFlightSetAsides.delete(branch);
703
+ });
704
+ this.inFlightSetAsides.set(branch, moving);
705
+ await moving;
706
+ if (moved) {
707
+ log.warn(
708
+ `the "${branch}" working copy was a clone of another repository. Set aside at ${kept}; nothing was ` +
709
+ 'deleted, and it is being cloned fresh from the configured one. Work that was never pushed is in ' +
710
+ 'that folder: `git log` there shows it.',
711
+ );
712
+ }
713
+ }
714
+ } else if (onDisk) {
715
+ try {
716
+ // Migration for clones already on disk: re-stamp the tracking config
717
+ // the first time this process opens them, so a clone whose config
718
+ // drifted (duplicate fetch refspec / merge ref) is repaired before
719
+ // anything pulls it. Once per branch per process — the cached paths
720
+ // above return before reaching here.
721
+ await this.normalizeCloneConfig(repoDir, branch);
722
+ // Taken away meanwhile: nothing to adopt, clone below.
723
+ if (this.discardsOf(id) !== discardsAtAdoption) throw new Error('the working copy was set aside');
724
+ this.registerBranchDir(branch, workspaceDir);
725
+ return this.buildWorkspaceInfo(branch, workspaceDir);
726
+ } catch {
727
+ // No longer worth adopting — bootstrap below.
728
+ }
632
729
  }
633
730
 
634
731
  // Single-flight bootstrap. Concurrent callers for the same branch
635
732
  // share the first one's promise instead of stacking clones.
733
+ //
734
+ // Recorded before any of it: a clone takes seconds, and the save that
735
+ // replaces the repository can land in the middle of one. Whatever this
736
+ // bootstrap produces is about the repository configured NOW, so if the
737
+ // working copy is taken away while it runs (`forgetClone`) the result must
738
+ // not be published — see `adoptBootstrapped`.
739
+ const discardsAtBootstrap = this.discardsOf(workspaceIdForBranch(branch));
636
740
  let resolveBootstrap!: () => void;
637
741
  let rejectBootstrap!: (err: unknown) => void;
638
742
  const bootstrap = new Promise<void>((res, rej) => {
@@ -644,7 +748,7 @@ export class WorkspaceService implements IWorkspaceService {
644
748
  const existingBootstrap = this.inFlightBootstraps.get(branch);
645
749
  if (existingBootstrap) {
646
750
  await existingBootstrap;
647
- this.registerBranchDir(branch, workspaceDir);
751
+ this.adoptBootstrapped(branch, workspaceDir, discardsAtBootstrap);
648
752
  return this.buildWorkspaceInfo(branch, workspaceDir);
649
753
  }
650
754
  this.inFlightBootstraps.set(branch, bootstrap);
@@ -659,7 +763,7 @@ export class WorkspaceService implements IWorkspaceService {
659
763
  // naturally; seeding the remote is the KB startup phase's job, at boot.
660
764
  createdWorkspaceDir = await fs.mkdir(workspaceDir, { recursive: true });
661
765
  await this.cloneProcessMapForBranch(workspaceDir, branch);
662
- this.registerBranchDir(branch, workspaceDir);
766
+ this.adoptBootstrapped(branch, workspaceDir, discardsAtBootstrap);
663
767
  resolveBootstrap();
664
768
  } catch (err) {
665
769
  this.branchDirs.delete(branch);
@@ -706,6 +810,33 @@ export class WorkspaceService implements IWorkspaceService {
706
810
  };
707
811
  }
708
812
 
813
+ /**
814
+ * Publish a finished bootstrap's working copy to the branch→directory cache
815
+ * — unless the copy was taken out from under it while it ran.
816
+ *
817
+ * The save that replaces the knowledge-base repository sets every working
818
+ * copy aside and clears this cache (`forgetClone`), and a clone takes
819
+ * seconds: a bootstrap already in flight when that happens finishes
820
+ * afterwards, and a plain `registerBranchDir` would put the path back. The
821
+ * directory is gone (set aside), so every reader from then on gets an
822
+ * ENOENT off the fast path and nothing ever re-clones it — a cache entry
823
+ * is exactly what stops that.
824
+ *
825
+ * Refused rather than retried here: the address this bootstrap cloned from
826
+ * is not the one configured now, so there is nothing to salvage. The caller
827
+ * is told, and opening the branch again clones it from the new address.
828
+ */
829
+ private adoptBootstrapped(branch: string, workspaceDir: string, discardsAtStart: number): void {
830
+ if (this.discardsOf(workspaceIdForBranch(branch)) !== discardsAtStart) {
831
+ this.branchDirs.delete(branch);
832
+ throw new Error(
833
+ `The knowledge-base repository was replaced while the "${branch}" working copy was being created, so ` +
834
+ 'that copy was set aside instead of being used. Open it again to get one of the new repository.',
835
+ );
836
+ }
837
+ this.registerBranchDir(branch, workspaceDir);
838
+ }
839
+
709
840
  /**
710
841
  * Await the in-flight bootstrap for a branch (if any). On reject —
711
842
  * meaning the clone rolled back — drop the branch→dir mapping so the
@@ -731,6 +862,98 @@ export class WorkspaceService implements IWorkspaceService {
731
862
  return this.inFlightBootstraps.has(branch);
732
863
  }
733
864
 
865
+ /**
866
+ * Whether the clone at `repoDir` belongs to a repository OTHER than the
867
+ * configured one.
868
+ *
869
+ * Errs towards NO — "could not tell" must never delete someone's work:
870
+ * an address that cannot be read (no `origin`, a git that would not run)
871
+ * leaves the clone alone, and only two addresses that are read and differ
872
+ * in more than their spelling count as different repositories (see
873
+ * `kb-fs/remote-url.ts`). An UNCONFIGURED address answers no as well: a
874
+ * process that does not know where the repository is has no business
875
+ * declaring a clone stale.
876
+ */
877
+ private async isCloneOfAnotherRepository(repoDir: string): Promise<boolean> {
878
+ const configured = this.kbRepoUrl().trim();
879
+ if (configured === '') return false;
880
+ let origin: string;
881
+ try {
882
+ const { stdout } = await this.gitRunner.run(repoDir, ['config', '--get', 'remote.origin.url']);
883
+ origin = stdout.trim();
884
+ } catch {
885
+ return false;
886
+ }
887
+ if (origin === '' || sameRepository(origin, configured)) return false;
888
+ // Normalizing strips the userinfo but KEEPS the query string, and the
889
+ // knowledge-base address is one that may carry its credential there (a
890
+ // signed URL — see `urlQuerySecrets`). So the address is scrubbed on the
891
+ // way into the log, exactly as the startup phase scrubs its own.
892
+ log.warn(
893
+ `the "${path.basename(path.dirname(repoDir))}" working copy is a clone of another repository ` +
894
+ `(${redactSecret(normalizeRepositoryAddress(origin), urlQuerySecrets(origin))}).`,
895
+ );
896
+ return true;
897
+ }
898
+
899
+ /**
900
+ * How many times each working copy has been taken out from under this
901
+ * process — see {@link forgetClone}. Deleting from the caches is not enough
902
+ * on a RUNNING server: work already in flight against the copy that went
903
+ * away finishes afterwards and writes its result back into those very
904
+ * caches. So every such reader records the count it started with and checks
905
+ * it before publishing: a count that moved means what it learned is about a
906
+ * working copy that no longer exists, and is dropped.
907
+ *
908
+ * Keyed by workspace id, which is what the phase announces and what both
909
+ * readers can derive (`workspaceIdForBranch`).
910
+ */
911
+ private readonly discardCount = new Map<string, number>();
912
+
913
+ /**
914
+ * Both readers and `forgetClone` go through this, so a caller's spelling of
915
+ * a workspace id can never make them disagree about which working copy is
916
+ * meant: the id is taken back to its branch and spelled afresh, which is the
917
+ * one form the phase announces.
918
+ */
919
+ private discardKey(workspaceId: string): string {
920
+ return workspaceIdForBranch(branchForWorkspaceId(workspaceId));
921
+ }
922
+
923
+ private discardsOf(workspaceId: string): number {
924
+ return this.discardCount.get(this.discardKey(workspaceId)) ?? 0;
925
+ }
926
+
927
+ /**
928
+ * Forget everything this process remembers about a working copy that was
929
+ * taken away underneath it — the KB startup phase setting aside a clone of a
930
+ * repository that was replaced.
931
+ *
932
+ * At boot there is nothing to forget; this is for the save that changes the
933
+ * address on a RUNNING server, where `branchDirs` would otherwise keep
934
+ * answering with the path of a directory that is gone and no later caller
935
+ * would ever re-clone it. The branch NAME is deliberately left in
936
+ * `branchesEverHeardOf` — the branch was real, and that record outlives
937
+ * its clone.
938
+ */
939
+ forgetClone(workspaceId: string): void {
940
+ const branch = branchForWorkspaceId(workspaceId);
941
+ const workspaceDir = path.join(this.workspacesRoot, workspaceId);
942
+ const repoDir = path.join(workspaceDir, this.kbDirName);
943
+ this.discardCount.set(this.discardKey(workspaceId), this.discardsOf(workspaceId) + 1);
944
+ this.branchDirs.delete(branch);
945
+ this.lastFetchAt.delete(repoDir);
946
+ this.lastFetchOk.delete(repoDir);
947
+ // Dropped as well as un-stamped: the promise still running belongs to the
948
+ // working copy that went away, so a caller that joined it would be told a
949
+ // fetch of a directory that is gone refreshed the one that replaced it.
950
+ // The count above is what stops that promise re-stamping the TTL when it
951
+ // finally settles.
952
+ this.inFlightFetches.delete(repoDir);
953
+ // Keyed by branch, not by directory — see `stampCredentialHelper`.
954
+ this.stampedCredentialFingerprint.delete(branch);
955
+ }
956
+
734
957
  /**
735
958
  * Bring an adopted clone's config back to what a fresh clone would carry.
736
959
  *
@@ -1266,14 +1489,23 @@ export class WorkspaceService implements IWorkspaceService {
1266
1489
  // This driver runs outside the git layer's per-workspace mutex, so it may
1267
1490
  // only run the safe implicit-fetch shape — see `SAFE_IMPLICIT_FETCH_ARGS`
1268
1491
  // in `kb-fs/clone-config.ts` for the full rationale.
1492
+ // What this fetch is ABOUT: the working copy in that directory as it is
1493
+ // now. Should the repository be replaced while the fetch runs, the copy is
1494
+ // set aside and a fresh one is cloned to the SAME path — so stamping the
1495
+ // result afterwards would hand the new clone a freshness it never earned
1496
+ // (or a failure that was the old one's), and the next reader would skip
1497
+ // refreshing it for the whole TTL.
1498
+ const discardsAtFetch = this.discardsOf(workspaceId);
1499
+ const stillTheSameClone = (): boolean => this.discardsOf(workspaceId) === discardsAtFetch;
1269
1500
  const promise = this.gitRunner
1270
1501
  .run(repoDir, [...SAFE_IMPLICIT_FETCH_ARGS])
1271
1502
  .then(() => {
1503
+ if (!stillTheSameClone()) return;
1272
1504
  this.lastFetchAt.set(repoDir, Date.now());
1273
1505
  this.lastFetchOk.set(repoDir, true);
1274
1506
  })
1275
1507
  .catch((err) => {
1276
- this.lastFetchOk.set(repoDir, false);
1508
+ if (stillTheSameClone()) this.lastFetchOk.set(repoDir, false);
1277
1509
  log.warn('git fetch origin failed:', { detail: redactError(err, this.gitRunner.credentials.token()) });
1278
1510
  })
1279
1511
  .finally(() => {
@@ -1689,10 +1921,10 @@ export class WorkspaceService implements IWorkspaceService {
1689
1921
  zipWsPath: string,
1690
1922
  destDirWsPath?: string,
1691
1923
  /**
1692
- * Per-extracted-path write guard (ontology-session boundary). Called with
1693
- * each entry's workspace-relative target before it is written; if it throws,
1694
- * the entry is skipped (with the thrown message as the reason) rather than
1695
- * aborting the whole extraction. Omitted by non-agent / human callers.
1924
+ * Per-extracted-path write guard. Called with each entry's
1925
+ * workspace-relative target before it is written; if it throws, the entry
1926
+ * is skipped (with the thrown message as the reason) rather than aborting
1927
+ * the whole extraction. Omitted by non-agent / human callers.
1696
1928
  */
1697
1929
  guardWrite?: (wsRelativePath: string) => Promise<void>,
1698
1930
  ): Promise<UnzipResult> {
@@ -1813,17 +2045,17 @@ export class WorkspaceService implements IWorkspaceService {
1813
2045
  const relForReport = path
1814
2046
  .relative(workspaceDir, targetAbsolute)
1815
2047
  .replace(/\\/g, '/');
1816
- // Ontology-session boundary: a write-blocked (or cross-ontology) entry is
1817
- // skipped, not extracted, so an archive can't be a write path around it.
1818
- // Runs before any filesystem side effect — including directory creation —
1819
- // so a blocked entry leaves nothing behind on disk.
2048
+ // An entry the write guard refuses is skipped, not extracted, so an
2049
+ // archive can't be a way around whatever the guard enforces. Runs before
2050
+ // any filesystem side effect — including directory creation — so a
2051
+ // refused entry leaves nothing behind on disk.
1820
2052
  const allowEntry = async (): Promise<boolean> => {
1821
2053
  if (!guardWrite) return true;
1822
2054
  try {
1823
2055
  await guardWrite(relForReport);
1824
2056
  return true;
1825
2057
  } catch (err) {
1826
- skipped.push({ path: rawName, reason: err instanceof Error ? err.message : 'Blocked by the ontology-session boundary' });
2058
+ skipped.push({ path: rawName, reason: err instanceof Error ? err.message : 'Refused before it was written' });
1827
2059
  return false;
1828
2060
  }
1829
2061
  };