@bevel-software/platform-core-backend 0.29.0 → 0.31.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 (307) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/__tests__/test-repositories.d.ts +3 -0
  4. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  5. package/dist/__tests__/test-repositories.js +18 -0
  6. package/dist/__tests__/test-repositories.js.map +1 -0
  7. package/dist/core/catalog-revision.js +3 -3
  8. package/dist/core/create-core-server.d.ts.map +1 -1
  9. package/dist/core/create-core-server.js +3 -2
  10. package/dist/core/create-core-server.js.map +1 -1
  11. package/dist/core/create-core-services.d.ts +3 -0
  12. package/dist/core/create-core-services.d.ts.map +1 -1
  13. package/dist/core/create-core-services.js +35 -5
  14. package/dist/core/create-core-services.js.map +1 -1
  15. package/dist/core/lifecycle.d.ts +8 -0
  16. package/dist/core/lifecycle.d.ts.map +1 -1
  17. package/dist/core/lifecycle.js +4 -0
  18. package/dist/core/lifecycle.js.map +1 -1
  19. package/dist/modules/access/access-control.interface.d.ts +20 -0
  20. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  21. package/dist/modules/access/access-control.service.d.ts +32 -1
  22. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-control.service.js +135 -17
  24. package/dist/modules/access/access-control.service.js.map +1 -1
  25. package/dist/modules/access/access.routes.d.ts.map +1 -1
  26. package/dist/modules/access/access.routes.js +20 -6
  27. package/dist/modules/access/access.routes.js.map +1 -1
  28. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  29. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  30. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  31. package/dist/modules/audit/agent-audit.service.js +66 -6
  32. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  33. package/dist/modules/audit/audit.contract.d.ts +52 -8
  34. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  35. package/dist/modules/audit/audit.contract.js +37 -1
  36. package/dist/modules/audit/audit.contract.js.map +1 -1
  37. package/dist/modules/audit/audit.routes.d.ts +1 -1
  38. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  39. package/dist/modules/audit/audit.routes.js +15 -13
  40. package/dist/modules/audit/audit.routes.js.map +1 -1
  41. package/dist/modules/audit/request-audit.d.ts +21 -10
  42. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  43. package/dist/modules/audit/request-audit.js +26 -10
  44. package/dist/modules/audit/request-audit.js.map +1 -1
  45. package/dist/modules/database/core-schema.d.ts +22 -3
  46. package/dist/modules/database/core-schema.d.ts.map +1 -1
  47. package/dist/modules/database/core-schema.js +15 -6
  48. package/dist/modules/database/core-schema.js.map +1 -1
  49. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  50. package/dist/modules/diff/diff.service.js +7 -1
  51. package/dist/modules/diff/diff.service.js.map +1 -1
  52. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  53. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  54. package/dist/modules/kb-fs/git-dir.js +58 -0
  55. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  56. package/dist/modules/kb-fs/index.d.ts +3 -0
  57. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  58. package/dist/modules/kb-fs/index.js +3 -1
  59. package/dist/modules/kb-fs/index.js.map +1 -1
  60. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  61. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  62. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  63. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  64. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  65. package/dist/modules/kb-fs/mutex.js +14 -0
  66. package/dist/modules/kb-fs/mutex.js.map +1 -1
  67. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  68. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  69. package/dist/modules/kb-fs/ref-files.js +249 -0
  70. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  71. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  72. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  73. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  74. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  75. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  76. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  77. package/dist/modules/mcp/mcp.routes.js +67 -0
  78. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  79. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  80. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  81. package/dist/modules/mcp/mcp.service.js +257 -50
  82. package/dist/modules/mcp/mcp.service.js.map +1 -1
  83. package/dist/modules/mcp/request-context.d.ts +25 -0
  84. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  85. package/dist/modules/mcp/request-context.js +11 -0
  86. package/dist/modules/mcp/request-context.js.map +1 -0
  87. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  88. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  89. package/dist/modules/mcp/shared-definitions.js +117 -0
  90. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  91. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  92. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  93. package/dist/modules/mcp/surface-cache.js +104 -0
  94. package/dist/modules/mcp/surface-cache.js.map +1 -0
  95. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  96. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  97. package/dist/modules/plugins/teams.routes.js +9 -1
  98. package/dist/modules/plugins/teams.routes.js.map +1 -1
  99. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  100. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  101. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  102. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  103. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  104. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  105. package/dist/modules/tool-helpers/tool-context.js +33 -12
  106. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  107. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  108. package/dist/modules/tool-helpers/tool.contract.d.ts +10 -2
  109. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  110. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  114. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  115. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
  117. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  118. package/dist/modules/workflow/git/git.service.d.ts +142 -4
  119. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  120. package/dist/modules/workflow/git/git.service.js +645 -128
  121. package/dist/modules/workflow/git/git.service.js.map +1 -1
  122. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  123. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  124. package/dist/modules/workflow/read-on-branch.js +58 -0
  125. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  126. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  127. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  128. package/dist/modules/workflow/workflow.routes.js +22 -41
  129. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  130. package/dist/modules/workflow/workflow.service.d.ts +48 -8
  131. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  132. package/dist/modules/workflow/workflow.service.js +163 -16
  133. package/dist/modules/workflow/workflow.service.js.map +1 -1
  134. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  135. package/dist/modules/workspace/agent-access.gate.js +2 -2
  136. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  137. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  138. package/dist/modules/workspace/bounded-matcher.js +261 -0
  139. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  140. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  141. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  142. package/dist/modules/workspace/fetch-instant.js +13 -0
  143. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  144. package/dist/modules/workspace/index.d.ts +9 -0
  145. package/dist/modules/workspace/index.d.ts.map +1 -1
  146. package/dist/modules/workspace/index.js +10 -0
  147. package/dist/modules/workspace/index.js.map +1 -1
  148. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  149. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  150. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  151. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  152. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  153. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  154. package/dist/modules/workspace/kb-repository.js +811 -0
  155. package/dist/modules/workspace/kb-repository.js.map +1 -0
  156. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  157. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  158. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  159. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  160. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  161. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  162. package/dist/modules/workspace/routine-write-policy.js +0 -5
  163. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  164. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  165. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  166. package/dist/modules/workspace/set-aside-clone.js +139 -0
  167. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  168. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  169. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  170. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  171. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  172. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  173. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  174. package/dist/modules/workspace/workspace.service.js +240 -161
  175. package/dist/modules/workspace/workspace.service.js.map +1 -1
  176. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  177. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  178. package/dist/modules/workspace/workspace.tools.js +162 -336
  179. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  180. package/dist/shared/git.contract.d.ts +7 -0
  181. package/dist/shared/git.contract.d.ts.map +1 -1
  182. package/dist/shared/git.contract.js +9 -0
  183. package/dist/shared/git.contract.js.map +1 -1
  184. package/dist/shared/no-link-below.d.ts +10 -0
  185. package/dist/shared/no-link-below.d.ts.map +1 -0
  186. package/dist/shared/no-link-below.js +30 -0
  187. package/dist/shared/no-link-below.js.map +1 -0
  188. package/dist/test-setup.js +4 -0
  189. package/dist/test-setup.js.map +1 -1
  190. package/migrations/0018_agent_events_page.sql +11 -0
  191. package/migrations/meta/0018_snapshot.json +2429 -0
  192. package/migrations/meta/_journal.json +8 -1
  193. package/package.json +3 -3
  194. package/src/__tests__/test-repositories.ts +22 -0
  195. package/src/__tests__/workspace-service.ts +69 -0
  196. package/src/core/__tests__/lifecycle.test.ts +16 -0
  197. package/src/core/catalog-revision.ts +3 -3
  198. package/src/core/create-core-server.ts +3 -2
  199. package/src/core/create-core-services.ts +39 -4
  200. package/src/core/lifecycle.ts +10 -0
  201. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
  202. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  203. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  204. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  205. package/src/modules/access/access-control.interface.ts +22 -0
  206. package/src/modules/access/access-control.service.ts +138 -15
  207. package/src/modules/access/access.routes.ts +22 -6
  208. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  209. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  210. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  211. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  212. package/src/modules/audit/agent-audit.service.ts +66 -6
  213. package/src/modules/audit/audit.contract.ts +62 -10
  214. package/src/modules/audit/audit.routes.ts +14 -12
  215. package/src/modules/audit/request-audit.ts +28 -13
  216. package/src/modules/database/core-schema.ts +15 -6
  217. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  218. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  219. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  220. package/src/modules/diff/diff.service.ts +6 -4
  221. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  222. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  223. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  224. package/src/modules/kb-fs/git-dir.ts +53 -0
  225. package/src/modules/kb-fs/index.ts +3 -0
  226. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  227. package/src/modules/kb-fs/mutex.ts +16 -0
  228. package/src/modules/kb-fs/ref-files.ts +279 -0
  229. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  230. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  231. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  232. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  233. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  234. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  235. package/src/modules/mcp/mcp.routes.ts +68 -0
  236. package/src/modules/mcp/mcp.service.ts +315 -51
  237. package/src/modules/mcp/request-context.ts +34 -0
  238. package/src/modules/mcp/shared-definitions.ts +121 -0
  239. package/src/modules/mcp/surface-cache.ts +154 -0
  240. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  241. package/src/modules/plugins/teams.routes.ts +14 -1
  242. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  243. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  244. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  245. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  246. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  247. package/src/modules/tool-helpers/tool-context.ts +37 -18
  248. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  249. package/src/modules/tool-helpers/tool.contract.ts +10 -2
  250. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  251. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +7 -2
  252. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  253. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
  254. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
  255. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  256. package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
  257. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  258. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  259. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  260. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  261. package/src/modules/workflow/git/git.service.ts +719 -126
  262. package/src/modules/workflow/read-on-branch.ts +79 -0
  263. package/src/modules/workflow/workflow-hooks.ts +1 -1
  264. package/src/modules/workflow/workflow.routes.ts +21 -39
  265. package/src/modules/workflow/workflow.service.ts +203 -16
  266. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  267. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  268. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  269. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  270. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  271. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  272. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  273. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  274. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  275. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  276. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  277. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  278. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  279. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  280. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  281. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  282. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  283. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  284. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  285. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  286. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  287. package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
  288. package/src/modules/workspace/agent-access.gate.ts +2 -2
  289. package/src/modules/workspace/bounded-matcher.ts +328 -0
  290. package/src/modules/workspace/fetch-instant.ts +13 -0
  291. package/src/modules/workspace/index.ts +23 -0
  292. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  293. package/src/modules/workspace/kb-repository.ts +844 -0
  294. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  295. package/src/modules/workspace/routine-write-policy.ts +0 -15
  296. package/src/modules/workspace/set-aside-clone.ts +140 -0
  297. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  298. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  299. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  300. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  301. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  302. package/src/modules/workspace/workspace.service.ts +247 -169
  303. package/src/modules/workspace/workspace.tools.ts +179 -365
  304. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  305. package/src/shared/git.contract.ts +10 -0
  306. package/src/shared/no-link-below.ts +29 -0
  307. package/src/test-setup.ts +5 -0
@@ -6,7 +6,7 @@ import { logger } from '../../../shared/logging.js';
6
6
  // callback of its own, and a module logger of the same name would be
7
7
  // shadowed exactly where it is meant to be the default.
8
8
  const startupLog = logger('kb-startup');
9
- import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
9
+ import { branchForWorkspaceId, workspaceIdForBranch } from '../../../shared/workspace-id.js';
10
10
  import type { KbBranch, OnServerStart, ServerStartContext } from './on-server-start.js';
11
11
  import { git, lsRemoteHeads, stampIdentity, withTempDir } from './kb-git.js';
12
12
  import { GitRunError, type IGitRunner } from '../../../shared/git.contract.js';
@@ -20,7 +20,11 @@ import {
20
20
  } from '../../../shared/git-failure.js';
21
21
  import { redactSecret, urlQuerySecrets } from '../../../shared/redact-secret.js';
22
22
  import { normalizeRepositoryAddress, sameRepository } from '../../kb-fs/remote-url.js';
23
- import { setAsideClone, setAsideRootFor, setAsideStamp } from '../set-aside-clone.js';
23
+ import { setAsideClone, setAsideRepository, setAsideRootFor, setAsideStamp } from '../set-aside-clone.js';
24
+ import { workingCopyShape, type WorkingCopyShape } from '../kb-repository.js';
25
+ import type { FetchOptions, IKbRepository } from '../kb-repository.contract.js';
26
+ import { fetchInstant } from '../fetch-instant.js';
27
+ import { assertValidBranchName } from '../../kb-fs/branch-name.js';
24
28
 
25
29
  /**
26
30
  * The KB startup phase: run every registered {@link OnServerStart} step, in
@@ -129,6 +133,12 @@ export interface KbStartupRunnerOptions {
129
133
  * minute; a suite passes a short one.
130
134
  */
131
135
  unfinishedCloneQuietMs?: number;
136
+ /**
137
+ * The knowledge base's one repository, every branch workspace a worktree of
138
+ * it — THE SAME INSTANCE THE WORKSPACE SERVICE IS GIVEN (see
139
+ * `kb-repository.ts`). Built by the composition root, never here.
140
+ */
141
+ repository: IKbRepository;
132
142
  }
133
143
 
134
144
  const UNFINISHED_CLONE_QUIET_MS = 60_000;
@@ -140,10 +150,16 @@ const LONGEST_CLONE_MS = 600_000;
140
150
  * on every one of these is being worked on by something that will not stop.
141
151
  */
142
152
  const PASSES_OVER_ONE_WORKING_COPY = 4;
153
+ /**
154
+ * How recent a fetch of the repository the phase takes as current. Every
155
+ * branch it visits is a worktree of the one repository, so one fetch serves
156
+ * them all; a boot that visits 142 branches fetches once, not 142 times.
157
+ */
158
+ const STARTUP_FETCH_WINDOW_MS = 30_000;
143
159
 
144
- /** Whether `git clone` failed because its destination already held something, having written nothing. */
160
+ /** Whether `git clone` or `git worktree add` failed because its destination already held something, having written nothing. */
145
161
  function refusedAnOccupiedFolder(err: unknown): boolean {
146
- return err instanceof Error && /already exists and is not an empty directory/.test(err.message);
162
+ return err instanceof Error && /already exists and is not an empty directory|' already exists$/m.test(err.message);
147
163
  }
148
164
 
149
165
  /**
@@ -239,7 +255,24 @@ export interface RetryOptions {
239
255
  }
240
256
 
241
257
  export class KbStartupRunner {
242
- constructor(private readonly opts: KbStartupRunnerOptions) {}
258
+ private readonly repository: IKbRepository;
259
+ /**
260
+ * Branches deleted on the remote after the phase listed them: the
261
+ * repository's fetch (`--prune`) took their `origin/<branch>` away. Their
262
+ * worktrees are left as they are, and the phase commits and pushes nothing
263
+ * to them — a push would bring a deleted branch back.
264
+ */
265
+ private readonly goneFromRemote = new Set<string>();
266
+ /**
267
+ * When this run listed the remote's branches. A fetch that started before
268
+ * it may lack a deletion the listing already saw, so only a fetch since
269
+ * counts as current for the branches the run opens (`fetchedSince`).
270
+ */
271
+ private listedAt = 0;
272
+
273
+ constructor(private readonly opts: KbStartupRunnerOptions) {
274
+ this.repository = opts.repository;
275
+ }
243
276
 
244
277
  /** The run in progress, so two invokers share one rather than racing clones. */
245
278
  private inFlight: Promise<void> | null = null;
@@ -431,11 +464,19 @@ export class KbStartupRunner {
431
464
  'screen or GITHUB_TOKEN instead.',
432
465
  );
433
466
  }
467
+ // What a previous run found deleted may have been pushed again since:
468
+ // each run asks afresh.
469
+ this.goneFromRemote.clear();
470
+ this.listedAt = fetchInstant();
434
471
  const heads = await this.ensureRemote();
435
472
  // AFTER the remote answered: the configured address is known to be a
436
473
  // repository we can reach, so a clone of a different one is stale, not
437
474
  // a casualty of a typo in the address.
438
475
  await this.reconcileClonesWithConfiguredRepository();
476
+ // Full clones from a release before worktrees become worktrees of the
477
+ // one repository — after the address check, so a clone of another
478
+ // repository is set aside rather than imported.
479
+ await this.convertLegacyClones();
439
480
  const ctx = this.buildContext(heads, handles);
440
481
 
441
482
  for (const step of this.opts.steps) {
@@ -486,6 +527,11 @@ export class KbStartupRunner {
486
527
  for (const h of handles.values()) {
487
528
  await this.finalize(h);
488
529
  }
530
+ // The repository's garbage collection, once per boot, with every
531
+ // branch's work landed and nothing served yet: automatic collection is
532
+ // off on the repository, where a commit in one worktree would collect
533
+ // beside every other worktree's work.
534
+ await this.repository.maintain();
489
535
  } catch (err) {
490
536
  // Every exit carries a scrubbed message: the port scrubs only what the
491
537
  // environment holds, and a token saved on the setup screen is the one
@@ -674,19 +720,32 @@ export class KbStartupRunner {
674
720
  */
675
721
  private async reconcileClonesWithConfiguredRepository(): Promise<void> {
676
722
  const configured = this.opts.kbRepoUrl();
723
+ // One folder per run, so what was set aside together stays together.
724
+ const stamp = setAsideStamp();
725
+ const setAsideRoot = setAsideRootFor(this.opts.workspacesRoot, this.opts.setAsideRoot);
726
+ // The repository first: its address is every worktree's. Even with no
727
+ // workspaces root — the repository lives outside it, and a worktree added
728
+ // later would fetch from whatever address it holds.
729
+ //
730
+ // The root is read BEFORE anything moves, and only an absent root counts
731
+ // as "no workspaces": one that cannot be read (a permission or I/O error)
732
+ // stops the phase, rather than letting the repository move without the
733
+ // worktrees it could not see.
677
734
  let entries: import('node:fs').Dirent[];
678
735
  try {
679
736
  entries = await fs.readdir(this.opts.workspacesRoot, { withFileTypes: true });
680
- } catch {
681
- return; // no workspaces yet
737
+ } catch (err) {
738
+ const code = (err as NodeJS.ErrnoException).code;
739
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') throw err;
740
+ entries = []; // no workspaces yet
682
741
  }
683
- // One folder per run, so what was set aside together stays together.
684
- const stamp = setAsideStamp();
685
- const setAsideRoot = setAsideRootFor(this.opts.workspacesRoot, this.opts.setAsideRoot);
742
+ await this.reconcileRepositoryWithConfigured(configured, path.join(setAsideRoot, stamp));
686
743
  for (const entry of entries) {
687
744
  if (!entry.isDirectory()) continue;
688
745
  const repoDir = path.join(this.opts.workspacesRoot, entry.name, this.opts.kbDirName);
689
- const hasGit = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
746
+ // A full clone only: a worktree reads the repository's address, and went
747
+ // (or stayed) with it above.
748
+ const hasGit = await fs.stat(path.join(repoDir, '.git')).then((s) => s.isDirectory(), () => false);
690
749
  if (!hasGit) continue;
691
750
  let origin: string;
692
751
  try {
@@ -721,6 +780,272 @@ export class KbStartupRunner {
721
780
  await this.announceDiscarded(entry.name);
722
781
  }
723
782
  }
783
+ /**
784
+ * The repository's half of {@link reconcileClonesWithConfiguredRepository}.
785
+ * The address is the repository's, shared by every worktree, so it is
786
+ * decided once: the same repository at a new address is re-pointed and
787
+ * kept; another repository is set aside TOGETHER WITH EVERY WORKTREE OF IT,
788
+ * into `dest` — a worktree's commits live in the repository, so neither is
789
+ * of use without the other. Nothing is deleted.
790
+ */
791
+ private async reconcileRepositoryWithConfigured(configured: string, dest: string): Promise<void> {
792
+ // Read and probed as itself (reads, and a probe fetch into a throwaway
793
+ // ref, which git locks); the one write, the re-pointing, is made by
794
+ // `setOriginUrl` as the repository's administrator. The phase runs before
795
+ // anything is served, so nothing opens a branch between the two.
796
+ const verdict = await (
797
+ async (): Promise<{ was: string } | 'keep' | 'repointed'> => {
798
+ const origin = await this.repository.originUrl();
799
+ if (origin === null || sameRepository(origin, configured)) return 'keep';
800
+ const was = this.redact(normalizeRepositoryAddress(origin));
801
+ // Any commit of ours will do: a common ancestor of one is a common
802
+ // ancestor of the repository's history.
803
+ const ours = (
804
+ await git(this.opts.gitRunner, this.repository.dir, [
805
+ 'for-each-ref', '--count=1', '--format=%(objectname)', 'refs/heads', 'refs/remotes/origin',
806
+ ]).catch(() => '')
807
+ ).trim();
808
+ if (ours !== '' && (await this.sharesHistoryWithConfigured(this.repository.dir, configured, ours))) {
809
+ await this.repository.setOriginUrl(configured);
810
+ startupLog.warn(
811
+ `the knowledge-base repository was fetched from ${was}, whose history the configured repository ` +
812
+ 'holds: the same repository at a new address. Pointed at the configured address and kept, with ' +
813
+ 'every worktree and their unpushed work.',
814
+ );
815
+ return 'repointed';
816
+ }
817
+ return { was };
818
+ }
819
+ )();
820
+ if (typeof verdict === 'string') return;
821
+ const { was } = verdict;
822
+ // Every worktree's work leaves first, and may stop the phase: see
823
+ // `beforeCloneSetAside`.
824
+ const worktreeIds: string[] = [];
825
+ for (const entry of await fs.readdir(this.opts.workspacesRoot, { withFileTypes: true }).catch(() => [])) {
826
+ if (!entry.isDirectory()) continue;
827
+ const dir = path.join(this.opts.workspacesRoot, entry.name, this.opts.kbDirName);
828
+ if (await fs.stat(path.join(dir, '.git')).then((s) => s.isFile(), () => false)) worktreeIds.push(entry.name);
829
+ }
830
+ for (const id of worktreeIds) await this.opts.beforeCloneSetAside?.(id);
831
+ const moved = await setAsideRepository(this.repository, this.opts.workspacesRoot, this.opts.kbDirName, dest);
832
+ startupLog.warn(
833
+ `the knowledge-base repository is of another repository (${was}). It and its ${moved.length} worktree(s) were ` +
834
+ `set aside together at ${dest}; nothing was deleted, and a fresh repository is made from the configured ` +
835
+ 'one. Work that was never pushed is in that folder: `git log` in a worktree there shows it.',
836
+ );
837
+ for (const id of moved) await this.announceDiscarded(id);
838
+ }
839
+
840
+ /**
841
+ * Convert every full clone under the workspaces root — what a release before
842
+ * worktrees left there — into a worktree of the one repository, ONE AT A
843
+ * TIME, without losing a commit:
844
+ *
845
+ * 1. the clone's branch tip is imported into the repository as the branch's
846
+ * local ref (`git fetch <clone> +refs/heads/<b>:refs/heads/<b>`), so
847
+ * commits the remote lacks are in the repository before anything moves,
848
+ * and with it every stash entry and other local ref the clone holds,
849
+ * under `refs/kb-converted/<workspace id>/`;
850
+ * 2. the clone is moved aside, beside where it was (one rename);
851
+ * 3. the worktree is added at the imported tip, and checked to be there;
852
+ * 4. the files git ignores in the clone, in no commit, are copied into it;
853
+ * 5. the clone is renamed `.<kb>.converted`: the one step that says the
854
+ * copy is complete, taken before the worktree is used, so a clone left
855
+ * behind after it is never copied from again (it would overwrite edits
856
+ * made since); a rename that fails undoes the conversion;
857
+ * 6. only then is the clone removed. A removal that fails leaves the
858
+ * `.converted` folder, which the next boot removes and nothing reads.
859
+ *
860
+ * A clone that cannot be converted — unreadable objects, uncommitted changes
861
+ * that are not anybody's to discard, a branch it is not on — is put back
862
+ * where it was and keeps working as a clone; the branch is logged, and the
863
+ * next boot tries again. One clone never stops the phase.
864
+ *
865
+ * A conversion a crash cut short is finished or undone first: the moved
866
+ * clone's ignored files are copied and it is removed when its worktree is
867
+ * in place (its tip was imported before it moved, and the worktree was not
868
+ * used yet: the rename of step 5 comes first), and put back when it is not.
869
+ */
870
+ private async convertLegacyClones(): Promise<void> {
871
+ let entries: import('node:fs').Dirent[];
872
+ try {
873
+ entries = await fs.readdir(this.opts.workspacesRoot, { withFileTypes: true });
874
+ } catch {
875
+ return;
876
+ }
877
+ let converted = 0;
878
+ const kept: string[] = [];
879
+ for (const entry of entries) {
880
+ if (!entry.isDirectory()) continue;
881
+ const workspaceDir = path.join(this.opts.workspacesRoot, entry.name);
882
+ const repoDir = path.join(workspaceDir, this.opts.kbDirName);
883
+ const aside = path.join(workspaceDir, `.${this.opts.kbDirName}.converting`);
884
+ const convertedClone = path.join(workspaceDir, `.${this.opts.kbDirName}.converted`);
885
+ await this.removeConvertedClone(branchForWorkspaceId(entry.name), convertedClone);
886
+ try {
887
+ await this.finishInterruptedConversion(repoDir, aside, convertedClone);
888
+ } catch (err) {
889
+ // Both folders stay as they are; the next start looks again.
890
+ kept.push(branchForWorkspaceId(entry.name));
891
+ startupLog.warn(
892
+ `an interrupted conversion of the "${branchForWorkspaceId(entry.name)}" working copy could not be finished (` +
893
+ `${this.redact(err instanceof Error ? err.message : String(err))}). The moved clone is kept as it is, and the next start tries again.`,
894
+ );
895
+ continue;
896
+ }
897
+ const branch = branchForWorkspaceId(entry.name);
898
+ let shape: WorkingCopyShape;
899
+ try {
900
+ shape = await workingCopyShape(repoDir);
901
+ } catch (err) {
902
+ // A `.git` that could not be read has shown nothing: left as it is,
903
+ // named, and looked at again by the next start.
904
+ kept.push(branch);
905
+ startupLog.warn(
906
+ `the "${branch}" working copy could not be inspected for conversion (` +
907
+ `${this.redact(err instanceof Error ? err.message : String(err))}). It is left as it is, and the next start tries again.`,
908
+ );
909
+ continue;
910
+ }
911
+ if (shape !== 'clone') continue;
912
+ try {
913
+ if (workspaceIdForBranch(branch) !== entry.name) continue;
914
+ assertValidBranchName(branch);
915
+ } catch {
916
+ continue;
917
+ }
918
+ // A clone with no commit on its branch is an unfinished one, which the
919
+ // branch's own pass sets aside: there is no tip to import.
920
+ const tip = await git(this.opts.gitRunner, repoDir, [
921
+ 'for-each-ref', '--format=%(objectname)', `refs/heads/${branch}`,
922
+ ]).catch(() => 'unreadable');
923
+ if (tip.trim() === '') continue;
924
+ try {
925
+ await this.convertOneClone(branch, repoDir, aside, convertedClone);
926
+ converted++;
927
+ } catch (err) {
928
+ kept.push(branch);
929
+ startupLog.warn(
930
+ `the "${branch}" working copy is still a full clone: it could not be converted to a worktree (` +
931
+ `${this.redact(err instanceof Error ? err.message : String(err))}). It keeps working as a clone, ` +
932
+ 'nothing in it was changed, and the next start tries again.',
933
+ );
934
+ }
935
+ }
936
+ if (converted > 0 || kept.length > 0) {
937
+ startupLog.info(
938
+ `converted ${converted} full clone(s) to worktrees of the knowledge-base repository` +
939
+ (kept.length > 0 ? `; ${kept.length} left as they are, to be tried again at the next start (${kept.join(', ')})` : '') +
940
+ '.',
941
+ );
942
+ }
943
+ }
944
+
945
+ private async convertOneClone(branch: string, repoDir: string, aside: string, converted: string): Promise<void> {
946
+ const onBranch = (await git(this.opts.gitRunner, repoDir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).trim();
947
+ if (onBranch !== branch) throw new Error(`it is on "${onBranch}", not on its own branch`);
948
+ // Uncommitted work is in the working tree, not in a commit the import can
949
+ // carry; the clone keeps it, and is converted once it is committed.
950
+ const dirty = (await git(this.opts.gitRunner, repoDir, ['status', '--porcelain=v1'])).trim();
951
+ if (dirty !== '') throw new Error('it has uncommitted changes');
952
+ const tip = await this.repository.importBranch(repoDir, branch);
953
+ await this.importLocalRefs(repoDir, branch);
954
+ await fs.rename(repoDir, aside);
955
+ try {
956
+ await this.repository.addWorktree(branch, repoDir, { allowLocalOnly: true, keepLocalTip: true });
957
+ const head = (await git(this.opts.gitRunner, repoDir, ['rev-parse', 'HEAD'])).trim();
958
+ if (head !== tip) throw new Error(`the worktree came up at ${head}, not at the clone's tip ${tip}`);
959
+ await this.copyIgnoredFiles(aside, repoDir);
960
+ await fs.rename(aside, converted);
961
+ } catch (err) {
962
+ await this.undoConversion(repoDir, aside);
963
+ throw err;
964
+ }
965
+ await this.removeConvertedClone(branch, converted);
966
+ }
967
+
968
+ /**
969
+ * The clone's stash and its other local refs, into the repository before
970
+ * the clone goes (see `KbRepository.importLocalRefs`), and said where.
971
+ */
972
+ private async importLocalRefs(cloneDir: string, branch: string): Promise<void> {
973
+ const kept = await this.repository.importLocalRefs(cloneDir, branch, workspaceIdForBranch(branch));
974
+ if (kept.length === 0) return;
975
+ startupLog.info(
976
+ `the "${branch}" clone's stash and other local refs are kept in the knowledge-base repository (${kept.length}): ` +
977
+ `${kept.join(', ')}. \`git log <ref>\` in its worktree shows each.`,
978
+ );
979
+ }
980
+
981
+ /** Undone: the worktree goes, the clone comes back exactly as it was. */
982
+ private async undoConversion(repoDir: string, aside: string): Promise<void> {
983
+ await fs.rm(repoDir, { recursive: true, force: true }).catch(() => undefined);
984
+ await this.repository.prune().catch(() => undefined);
985
+ await fs.rename(aside, repoDir);
986
+ }
987
+
988
+ /**
989
+ * Remove a converted clone: its commits and ignored files are in the
990
+ * worktree already. One that cannot be removed is said and left; the next
991
+ * boot tries again, and nothing ever copies from it.
992
+ */
993
+ private async removeConvertedClone(branch: string, converted: string): Promise<void> {
994
+ await fs.rm(converted, { recursive: true, force: true }).catch((err: unknown) => {
995
+ startupLog.warn(
996
+ `the converted clone of "${branch}" could not be removed (` +
997
+ `${this.redact(err instanceof Error ? err.message : String(err))}). Nothing reads it; the next start tries again.`,
998
+ );
999
+ });
1000
+ }
1001
+
1002
+ /**
1003
+ * Copy the files git ignores in the clone at `from` into the worktree at
1004
+ * `to`: they are in no commit the import carried, yet they are the
1005
+ * clone's. Copied, not moved, so an undo puts the clone back whole; and
1006
+ * over whatever is there, so a copy a crash cut short (a partial file) is
1007
+ * made whole again before the clone goes.
1008
+ */
1009
+ private async copyIgnoredFiles(from: string, to: string): Promise<void> {
1010
+ const ignored = (
1011
+ await git(this.opts.gitRunner, from, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory'])
1012
+ )
1013
+ .split('\0')
1014
+ .map((rel) => rel.replace(/\/$/, ''))
1015
+ .filter(Boolean);
1016
+ for (const rel of ignored) {
1017
+ await fs.mkdir(path.dirname(path.join(to, rel)), { recursive: true });
1018
+ await fs.cp(path.join(from, rel), path.join(to, rel), { recursive: true, force: true, verbatimSymlinks: true });
1019
+ }
1020
+ }
1021
+
1022
+ /** See {@link convertLegacyClones}: a conversion a crash cut short. */
1023
+ private async finishInterruptedConversion(repoDir: string, aside: string, converted: string): Promise<void> {
1024
+ if (!(await fs.access(aside).then(() => true, () => false))) return;
1025
+ // Only a checkout shown to be no worktree is removed: a link that could
1026
+ // not be read has shown nothing, and the error leaves both folders alone.
1027
+ if ((await workingCopyShape(repoDir)) === 'worktree') {
1028
+ // Not used since it was added: the clone is still `.converting`, so
1029
+ // the copy is finished (over a partial one) as the conversion would
1030
+ // have, and undone when it cannot be.
1031
+ try {
1032
+ // Imported before the clone moved; again, in case that was by a
1033
+ // release that imported its branch only.
1034
+ await this.importLocalRefs(aside, branchForWorkspaceId(path.basename(path.dirname(repoDir))));
1035
+ await this.copyIgnoredFiles(aside, repoDir);
1036
+ await fs.rename(aside, converted);
1037
+ } catch (err) {
1038
+ await this.undoConversion(repoDir, aside);
1039
+ throw err;
1040
+ }
1041
+ await this.removeConvertedClone(branchForWorkspaceId(path.basename(path.dirname(repoDir))), converted);
1042
+ return;
1043
+ }
1044
+ await fs.rm(repoDir, { recursive: true, force: true });
1045
+ await this.repository.prune().catch(() => undefined);
1046
+ await fs.rename(aside, repoDir);
1047
+ }
1048
+
724
1049
 
725
1050
  /**
726
1051
  * Whether the configured repository holds history this clone has too —
@@ -733,7 +1058,7 @@ export class KbStartupRunner {
733
1058
  * a wrong true would push a stranger's commits into the configured
734
1059
  * repository.
735
1060
  */
736
- private async sharesHistoryWithConfigured(repoDir: string, configured: string): Promise<boolean> {
1061
+ private async sharesHistoryWithConfigured(repoDir: string, configured: string, ours = 'HEAD'): Promise<boolean> {
737
1062
  const probe = 'refs/hexis-probe';
738
1063
  let theirs: string[] = [];
739
1064
  try {
@@ -746,7 +1071,7 @@ export class KbStartupRunner {
746
1071
  // One question for all of them: a common ancestor of HEAD and ANY of
747
1072
  // their branches is a common ancestor of HEAD and their merge. Capped,
748
1073
  // so a remote with thousands of branches cannot outgrow a command line.
749
- await git(this.opts.gitRunner, repoDir, ['merge-base', 'HEAD', ...theirs.slice(0, 200)]);
1074
+ await git(this.opts.gitRunner, repoDir, ['merge-base', ours, ...theirs.slice(0, 200)]);
750
1075
  return true;
751
1076
  } catch {
752
1077
  return false;
@@ -800,8 +1125,13 @@ export class KbStartupRunner {
800
1125
  const workspaceDir = path.join(this.opts.workspacesRoot, workspaceIdForBranch(branch));
801
1126
  const repoDir = path.join(workspaceDir, this.opts.kbDirName);
802
1127
  for (let pass = 1; ; pass++) {
803
- const hasGit = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
804
- if (hasGit && (await this.hasCommit(repoDir))) return this.updateClone(branch, repoDir);
1128
+ // A worktree whose link to the repository is broken has a `.git` and is
1129
+ // no working copy: it is set aside as an unfinished clone is, below.
1130
+ const shape = await workingCopyShape(repoDir);
1131
+ const hasGit = shape !== 'none';
1132
+ if ((shape === 'worktree' || shape === 'clone') && (await this.hasCommit(repoDir))) {
1133
+ return this.updateClone(branch, repoDir, shape === 'worktree');
1134
+ }
805
1135
  if (pass > PASSES_OVER_ONE_WORKING_COPY) {
806
1136
  throw new Error('it kept changing while this start was preparing it; something else is working on it');
807
1137
  }
@@ -862,8 +1192,9 @@ export class KbStartupRunner {
862
1192
  }
863
1193
 
864
1194
  private async isUnfinishedClone(repoDir: string): Promise<boolean> {
865
- const hasGit = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
866
- return hasGit && !(await this.hasCommit(repoDir));
1195
+ const shape = await workingCopyShape(repoDir);
1196
+ if (shape === 'broken') return true;
1197
+ return shape !== 'none' && !(await this.hasCommit(repoDir));
867
1198
  }
868
1199
 
869
1200
  /**
@@ -882,7 +1213,11 @@ export class KbStartupRunner {
882
1213
  const giveUpAt = Date.now() + Math.max(this.opts.gitRunner.defaultTimeoutMs, LONGEST_CLONE_MS) + quietMs;
883
1214
  for (;;) {
884
1215
  if (!(await this.isUnfinishedClone(repoDir))) return false;
885
- const lastWrite = await newestChangeUnder(path.join(repoDir, '.git'));
1216
+ // A clone writes under its `.git` folder; a worktree being added
1217
+ // writes its checkout, its `.git` being a file.
1218
+ const dotGit = path.join(repoDir, '.git');
1219
+ const watched = (await fs.stat(dotGit).then((s) => s.isDirectory(), () => false)) ? dotGit : repoDir;
1220
+ const lastWrite = await newestChangeUnder(watched);
886
1221
  const quietFor = Date.now() - lastWrite;
887
1222
  if (quietFor >= quietMs) return true;
888
1223
  if (Date.now() > giveUpAt) throw new Error('an unfinished clone at its path is still being written to');
@@ -914,16 +1249,21 @@ export class KbStartupRunner {
914
1249
 
915
1250
  private async cloneFresh(branch: string, workspaceDir: string, repoDir: string): Promise<string> {
916
1251
  await fs.mkdir(workspaceDir, { recursive: true });
917
- // Only what cannot be a clone is cleared out of the way: a folder that
918
- // gained a `.git` since the caller looked is somebody's clone, and git
1252
+ // Only what cannot be a working copy is cleared out of the way: a folder
1253
+ // that gained a `.git` since the caller looked is somebody's, and git
919
1254
  // refuses it below rather than this removing it.
920
1255
  if (!(await fs.access(path.join(repoDir, '.git')).then(() => true, () => false))) {
921
1256
  await fs.rm(repoDir, { recursive: true, force: true });
922
1257
  }
923
- await git(this.opts.gitRunner, workspaceDir, ['clone', '-b', branch, this.opts.kbRepoUrl(), repoDir]);
924
- // Said as soon as the clone is there, before its configuration: if a
925
- // command below fails, the clone stays on disk, and the retry finds it
926
- // and never comes back through here.
1258
+ // A worktree of the one repository, which is made (and fetched) by the
1259
+ // first branch that needs it: no history is copied per branch. The
1260
+ // repository carries the identity, the long-paths setting and the
1261
+ // credential helper for every worktree.
1262
+ // The phase opens every branch: the repository's one fetch in the window
1263
+ // brought them all, so each add reads it instead of asking the remote
1264
+ // for its branch again. A fetch that failed leaves the add to ask.
1265
+ await this.repository.fetch(this.fetchWindow());
1266
+ await this.repository.addWorktree(branch, repoDir, { fetchedSince: this.fetchedSince() });
927
1267
  try {
928
1268
  this.opts.onCloneCreated?.(workspaceIdForBranch(branch));
929
1269
  } catch (err) {
@@ -931,13 +1271,44 @@ export class KbStartupRunner {
931
1271
  detail: this.redact(err instanceof Error ? err.message : String(err)),
932
1272
  });
933
1273
  }
934
- await git(this.opts.gitRunner, repoDir, ['config', 'core.longpaths', 'true']);
935
- await stampIdentity(this.opts.gitRunner, repoDir);
936
1274
  return repoDir;
937
1275
  }
938
1276
 
939
- private async updateClone(branch: string, repoDir: string): Promise<string> {
940
- await git(this.opts.gitRunner, repoDir, ['fetch', 'origin', branch]);
1277
+ /**
1278
+ * The earliest a fetch of the repository may have started and still count
1279
+ * for this run: never before its listing, never older than the window. An
1280
+ * instant, so the repository compares it when it gets the reservation and
1281
+ * time spent waiting for it does not age the fetch out.
1282
+ */
1283
+ private fetchedSince(): number {
1284
+ return Math.max(this.listedAt, Date.now() - STARTUP_FETCH_WINDOW_MS);
1285
+ }
1286
+
1287
+ private fetchWindow(): FetchOptions {
1288
+ return { maxAgeMs: STARTUP_FETCH_WINDOW_MS, fetchedSince: this.fetchedSince() };
1289
+ }
1290
+
1291
+ private async updateClone(branch: string, repoDir: string, worktree = false): Promise<string> {
1292
+ if (worktree) {
1293
+ // ONE fetch for every worktree the phase visits, within the window: the
1294
+ // repository's. The phase is fail-closed, so a failed fetch stops it
1295
+ // with git's words, as the clone's own fetch did.
1296
+ if (!(await this.repository.fetch(this.fetchWindow()))) {
1297
+ const raw = `git fetch origin failed: ${String(this.repository.lastFetchFailure() ?? 'unknown failure')}`;
1298
+ throw new ClassifiedFailure(this.redact(raw), classifyGitFailure(raw));
1299
+ }
1300
+ // The branch was listed before the fetch and deleted on the remote
1301
+ // since: nothing to move the worktree to. Its commits stay where they
1302
+ // are, reachable from its branch, as any other branch's do.
1303
+ if (!(await this.hasRemoteBranch(repoDir, branch))) {
1304
+ this.goneFromRemote.add(branch);
1305
+ startupLog.warn(`branch "${branch}" was deleted on the remote during this start: its working copy is left as it is`);
1306
+ return repoDir;
1307
+ }
1308
+ this.goneFromRemote.delete(branch);
1309
+ } else {
1310
+ await git(this.opts.gitRunner, repoDir, ['fetch', 'origin', branch]);
1311
+ }
941
1312
  const local = (await git(this.opts.gitRunner, repoDir, ['rev-parse', 'HEAD'])).trim();
942
1313
  const remote = (await git(this.opts.gitRunner, repoDir, ['rev-parse', `origin/${branch}`])).trim();
943
1314
  if (local !== remote) {
@@ -950,6 +1321,22 @@ export class KbStartupRunner {
950
1321
  return repoDir;
951
1322
  }
952
1323
 
1324
+ /**
1325
+ * Whether the repository's remote-tracking ref for `branch` exists. False
1326
+ * only when git answered that it does not (`--verify --quiet`, exit 1);
1327
+ * any other failure is thrown.
1328
+ */
1329
+ private async hasRemoteBranch(repoDir: string, branch: string): Promise<boolean> {
1330
+ try {
1331
+ await git(this.opts.gitRunner, repoDir, ['rev-parse', '--quiet', '--verify', `refs/remotes/origin/${branch}^{commit}`]);
1332
+ return true;
1333
+ } catch (err) {
1334
+ const ran = err instanceof Error ? err.cause : undefined;
1335
+ if (ran instanceof GitRunError && !ran.timedOut && ran.exitCode === 1) return false;
1336
+ throw err;
1337
+ }
1338
+ }
1339
+
953
1340
  /**
954
1341
  * Whether the repository at `repoDir` has a commit checked out. False for a
955
1342
  * clone that was cut short.
@@ -975,8 +1362,14 @@ export class KbStartupRunner {
975
1362
  /** One commit per dirty branch; push; the replica carve-out on rejection. */
976
1363
  private async finalize(h: BranchHandle): Promise<void> {
977
1364
  if (!h.dirty) return;
1365
+ if (this.goneFromRemote.has(h.name)) {
1366
+ startupLog.warn(`${h.name}: deleted on the remote during this start; the phase's changes are not committed or pushed`);
1367
+ return;
1368
+ }
978
1369
  const repoDir = await h.repoDir();
979
- await stampIdentity(this.opts.gitRunner, repoDir);
1370
+ // A worktree's identity is the repository's, stamped when it was made; a
1371
+ // config write from here would contend with every other worktree's.
1372
+ if ((await workingCopyShape(repoDir)) !== 'worktree') await stampIdentity(this.opts.gitRunner, repoDir);
980
1373
  // Drop any PRE-EXISTING index state first (a crashed tool may have left
981
1374
  // edits staged): `git commit` publishes the whole index, and the phase
982
1375
  // must commit exactly its own staged set. The edits stay in the working
@@ -5,7 +5,6 @@ import { promisify } from 'node:util';
5
5
  import fs from 'node:fs/promises';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
- import { KbStartupRunner } from '../../kb-startup-runner.js';
9
8
  import { NodeGitRunner } from '../../../../workflow/git/node-git-runner.js';
10
9
  import type { OnServerStart, ServerStartContext, StepResult } from '../../on-server-start.js';
11
10
  import { GroupsToPluginsStep } from '../groups-to-plugins.step.js';
@@ -21,6 +20,7 @@ import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
21
20
  import { renderTemplateText } from '../template-source.js';
22
21
  import { TOOL_PREFIX_LINE, composeAgentInstructions, platformInstructions } from '../../../../agent-instructions/index.js';
23
22
  import { testKbContext } from '../../../../../__tests__/kb-context.js';
23
+ import { newKbStartupRunner } from '../../../../../__tests__/workspace-service.js';
24
24
 
25
25
  const execFileAsync = promisify(execFile);
26
26
 
@@ -102,7 +102,7 @@ async function seedUpstream(
102
102
  }
103
103
 
104
104
  function makeRunner(steps: OnServerStart[], templateDir: string = TEMPLATE_DIR) {
105
- return new KbStartupRunner({
105
+ return newKbStartupRunner({
106
106
  gitRunner: new NodeGitRunner(),
107
107
  kbRepoUrl: () => upstream,
108
108
  workspacesRoot,
@@ -781,7 +781,8 @@ describe('TemplateFilesStep: the guide copies earlier releases wrote', () => {
781
781
 
782
782
  const dir = await checkout(DEFAULT_BRANCH);
783
783
  expect((await fs.lstat(path.join(dir, 'AGENTS.md'))).isSymbolicLink()).toBe(true);
784
- expect(await fs.readlink(path.join(dir, 'AGENTS.md'))).toBe('conventions/ours.md');
784
+ // Windows reads the link back with its own separator.
785
+ expect((await fs.readlink(path.join(dir, 'AGENTS.md'))).replace(/\\/g, '/')).toBe('conventions/ours.md');
785
786
  expect(norm(await fs.readFile(path.join(dir, 'conventions/ours.md'), 'utf8'))).toBe(MANAGED_GUIDE_COPY);
786
787
  });
787
788
  });