@bevel-software/platform-core-backend 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/agent-guide/access-control.md +234 -0
  2. package/agent-guide/conventions.md +27 -0
  3. package/agent-guide/directory-structure.md +145 -0
  4. package/agent-guide/finding-things.md +7 -0
  5. package/agent-guide/introduction.md +27 -0
  6. package/agent-guide/skills.md +47 -0
  7. package/agent-guide/tool-manuals.md +217 -0
  8. package/agent-guide/where-a-new-file-goes.md +36 -0
  9. package/dist/assets.d.ts +7 -0
  10. package/dist/assets.d.ts.map +1 -1
  11. package/dist/assets.js +9 -0
  12. package/dist/assets.js.map +1 -1
  13. package/dist/core/core-ports.d.ts +11 -0
  14. package/dist/core/core-ports.d.ts.map +1 -1
  15. package/dist/core/core-ports.js.map +1 -1
  16. package/dist/core/create-core-server.d.ts.map +1 -1
  17. package/dist/core/create-core-server.js +13 -2
  18. package/dist/core/create-core-server.js.map +1 -1
  19. package/dist/core/create-core-services.d.ts +9 -0
  20. package/dist/core/create-core-services.d.ts.map +1 -1
  21. package/dist/core/create-core-services.js +14 -4
  22. package/dist/core/create-core-services.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/modules/access/access-control.interface.d.ts +9 -0
  28. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  29. package/dist/modules/access/access-control.service.d.ts +1 -0
  30. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  31. package/dist/modules/access/access-control.service.js +16 -0
  32. package/dist/modules/access/access-control.service.js.map +1 -1
  33. package/dist/modules/agent-guide/agent-guide.d.ts +139 -0
  34. package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -0
  35. package/dist/modules/agent-guide/agent-guide.js +191 -0
  36. package/dist/modules/agent-guide/agent-guide.js.map +1 -0
  37. package/dist/modules/agent-guide/agent-guide.tools.d.ts +24 -0
  38. package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -0
  39. package/dist/modules/agent-guide/agent-guide.tools.js +100 -0
  40. package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -0
  41. package/dist/modules/agent-guide/index.d.ts +4 -0
  42. package/dist/modules/agent-guide/index.d.ts.map +1 -0
  43. package/dist/modules/agent-guide/index.js +4 -0
  44. package/dist/modules/agent-guide/index.js.map +1 -0
  45. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +3 -2
  46. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  47. package/dist/modules/agent-instructions/agent-instructions.routes.js +3 -2
  48. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  49. package/dist/modules/agent-instructions/compose.d.ts +9 -6
  50. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  51. package/dist/modules/agent-instructions/compose.js +9 -6
  52. package/dist/modules/agent-instructions/compose.js.map +1 -1
  53. package/dist/modules/agent-instructions/index.d.ts +1 -1
  54. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  55. package/dist/modules/agent-instructions/index.js +1 -1
  56. package/dist/modules/agent-instructions/index.js.map +1 -1
  57. package/dist/modules/agent-instructions/shared-file-rules.d.ts +10 -50
  58. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
  59. package/dist/modules/agent-instructions/shared-file-rules.js +32 -85
  60. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  61. package/dist/modules/mcp/mcp.service.d.ts +29 -2
  62. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  63. package/dist/modules/mcp/mcp.service.js +113 -16
  64. package/dist/modules/mcp/mcp.service.js.map +1 -1
  65. package/dist/modules/mcp/tool-schema-guard.d.ts +105 -0
  66. package/dist/modules/mcp/tool-schema-guard.d.ts.map +1 -0
  67. package/dist/modules/mcp/tool-schema-guard.js +171 -0
  68. package/dist/modules/mcp/tool-schema-guard.js.map +1 -0
  69. package/dist/modules/plugins/plugins.tools.d.ts +36 -2
  70. package/dist/modules/plugins/plugins.tools.d.ts.map +1 -1
  71. package/dist/modules/plugins/plugins.tools.js +71 -14
  72. package/dist/modules/plugins/plugins.tools.js.map +1 -1
  73. package/dist/modules/settings/deployment-settings.service.d.ts +0 -7
  74. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  75. package/dist/modules/settings/deployment-settings.service.js +14 -53
  76. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  77. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  78. package/dist/modules/settings/setup.routes.js +3 -6
  79. package/dist/modules/settings/setup.routes.js.map +1 -1
  80. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  81. package/dist/modules/skills/skills.tools.js +58 -16
  82. package/dist/modules/skills/skills.tools.js.map +1 -1
  83. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +23 -4
  84. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  85. package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -1
  86. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +4 -0
  87. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  88. package/dist/modules/tool-manuals/tool-manuals.service.js +14 -0
  89. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  90. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +7 -0
  91. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.tools.js +66 -36
  93. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  94. package/dist/modules/tool-registry/description-length.d.ts +14 -14
  95. package/dist/modules/tool-registry/description-length.d.ts.map +1 -1
  96. package/dist/modules/tool-registry/description-length.js +24 -26
  97. package/dist/modules/tool-registry/description-length.js.map +1 -1
  98. package/dist/modules/tool-registry/guide-first.d.ts +23 -0
  99. package/dist/modules/tool-registry/guide-first.d.ts.map +1 -0
  100. package/dist/modules/tool-registry/guide-first.js +32 -0
  101. package/dist/modules/tool-registry/guide-first.js.map +1 -0
  102. package/dist/modules/tool-registry/tool-registry.d.ts +6 -0
  103. package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -1
  104. package/dist/modules/tool-registry/tool-registry.js +9 -2
  105. package/dist/modules/tool-registry/tool-registry.js.map +1 -1
  106. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts +449 -0
  107. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts.map +1 -0
  108. package/dist/modules/workflow/agent-tools/change-request-read-shape.js +481 -0
  109. package/dist/modules/workflow/agent-tools/change-request-read-shape.js.map +1 -0
  110. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts +73 -0
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -0
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +582 -0
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -0
  114. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +12 -1
  115. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/change-request-summary.js +5 -1
  117. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  118. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  119. package/dist/modules/workflow/agent-tools/workflow.tools.js +9 -0
  120. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  121. package/dist/modules/workflow/git/git.service.d.ts +210 -13
  122. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  123. package/dist/modules/workflow/git/git.service.js +456 -91
  124. package/dist/modules/workflow/git/git.service.js.map +1 -1
  125. package/dist/modules/workflow/git/merge-commit.d.ts +73 -0
  126. package/dist/modules/workflow/git/merge-commit.d.ts.map +1 -0
  127. package/dist/modules/workflow/git/merge-commit.js +89 -0
  128. package/dist/modules/workflow/git/merge-commit.js.map +1 -0
  129. package/dist/modules/workflow/git/pull-request.service.d.ts +94 -1
  130. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  131. package/dist/modules/workflow/git/pull-request.service.js +332 -37
  132. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  133. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +35 -0
  134. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  135. package/dist/modules/workflow/review-workflow/review-workflow.service.js +178 -12
  136. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  137. package/dist/modules/workflow/workflow.routes.d.ts +6 -2
  138. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  139. package/dist/modules/workflow/workflow.routes.js +7 -2
  140. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  141. package/dist/modules/workflow/workflow.service.d.ts +4 -0
  142. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  143. package/dist/modules/workflow/workflow.service.js +3 -0
  144. package/dist/modules/workflow/workflow.service.js.map +1 -1
  145. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +70 -0
  146. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  147. package/dist/modules/workspace/startup/kb-startup-runner.js +213 -20
  148. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  149. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  150. package/dist/modules/workspace/startup/steps/seed-tree.js +22 -27
  151. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  152. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +58 -52
  153. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  154. package/dist/modules/workspace/startup/steps/template-files.step.js +209 -223
  155. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  156. package/dist/modules/workspace/startup/steps/template-source.d.ts +5 -3
  157. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  158. package/dist/modules/workspace/startup/steps/template-source.js +5 -3
  159. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  160. package/dist/modules/workspace/workspace.tools.d.ts +10 -1
  161. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  162. package/dist/modules/workspace/workspace.tools.js +211 -18
  163. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  164. package/dist/shared/domain-errors.d.ts +11 -0
  165. package/dist/shared/domain-errors.d.ts.map +1 -1
  166. package/dist/shared/domain-errors.js +14 -0
  167. package/dist/shared/domain-errors.js.map +1 -1
  168. package/dist/shared/hidden-tools.d.ts +44 -0
  169. package/dist/shared/hidden-tools.d.ts.map +1 -0
  170. package/dist/shared/hidden-tools.js +13 -0
  171. package/dist/shared/hidden-tools.js.map +1 -0
  172. package/kb-template/.bevelignore +0 -5
  173. package/package.json +4 -3
  174. package/src/__tests__/kb-layout-config.test.ts +10 -100
  175. package/src/__tests__/packaged-assets-ship.test.ts +54 -0
  176. package/src/assets.ts +10 -0
  177. package/src/core/core-ports.ts +11 -0
  178. package/src/core/create-core-server.ts +13 -2
  179. package/src/core/create-core-services.ts +28 -4
  180. package/src/index.ts +2 -2
  181. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +58 -0
  182. package/src/modules/access/__tests__/access-control.platform-restore.test.ts +8 -7
  183. package/src/modules/access/__tests__/access-personal-plugin.test.ts +1 -18
  184. package/src/modules/access/access-control.interface.ts +15 -0
  185. package/src/modules/access/access-control.service.ts +21 -0
  186. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +328 -0
  187. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +189 -0
  188. package/src/modules/agent-guide/agent-guide.tools.ts +122 -0
  189. package/src/modules/agent-guide/agent-guide.ts +291 -0
  190. package/src/modules/agent-guide/index.ts +21 -0
  191. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +28 -121
  192. package/src/modules/agent-instructions/agent-instructions.routes.ts +3 -2
  193. package/src/modules/agent-instructions/compose.ts +9 -6
  194. package/src/modules/agent-instructions/index.ts +0 -3
  195. package/src/modules/agent-instructions/shared-file-rules.ts +31 -93
  196. package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +14 -3
  197. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +250 -0
  198. package/src/modules/mcp/__tests__/mcp.service.test.ts +31 -23
  199. package/src/modules/mcp/__tests__/tool-schema-guard.test.ts +266 -0
  200. package/src/modules/mcp/mcp.service.ts +137 -19
  201. package/src/modules/mcp/tool-schema-guard.ts +196 -0
  202. package/src/modules/plugins/__tests__/plugins.tools.test.ts +154 -4
  203. package/src/modules/plugins/plugins.tools.ts +75 -15
  204. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +26 -55
  205. package/src/modules/settings/deployment-settings.service.ts +13 -54
  206. package/src/modules/settings/setup.routes.ts +3 -6
  207. package/src/modules/skills/__tests__/skills.tools.description.test.ts +91 -0
  208. package/src/modules/skills/skills.tools.ts +62 -16
  209. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +57 -0
  210. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +73 -4
  211. package/src/modules/tool-manuals/tool-manuals.contract.ts +24 -4
  212. package/src/modules/tool-manuals/tool-manuals.service.ts +17 -0
  213. package/src/modules/tool-manuals/tool-manuals.tools.ts +74 -36
  214. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +160 -0
  215. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +61 -59
  216. package/src/modules/tool-registry/description-length.ts +24 -26
  217. package/src/modules/tool-registry/guide-first.ts +34 -0
  218. package/src/modules/tool-registry/tool-registry.ts +9 -2
  219. package/src/modules/workflow/__tests__/apply-failure.test.ts +6 -1
  220. package/src/modules/workflow/agent-tools/__tests__/change-request-read-shape.test.ts +705 -0
  221. package/src/modules/workflow/agent-tools/__tests__/change-request-read.tools.test.ts +1518 -0
  222. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +23 -2
  223. package/src/modules/workflow/agent-tools/change-request-read-shape.ts +712 -0
  224. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +724 -0
  225. package/src/modules/workflow/agent-tools/change-request-summary.ts +5 -1
  226. package/src/modules/workflow/agent-tools/workflow.tools.ts +8 -0
  227. package/src/modules/workflow/git/__tests__/git.service.appliedChange.test.ts +285 -0
  228. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
  229. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +334 -0
  230. package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +72 -2
  231. package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +24 -2
  232. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +620 -1
  233. package/src/modules/workflow/git/git.service.ts +537 -94
  234. package/src/modules/workflow/git/merge-commit.ts +88 -0
  235. package/src/modules/workflow/git/pull-request.service.ts +380 -54
  236. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +7 -1
  237. package/src/modules/workflow/review-workflow/__tests__/merge-records-own-commit.test.ts +407 -0
  238. package/src/modules/workflow/review-workflow/review-workflow.service.ts +189 -11
  239. package/src/modules/workflow/workflow.routes.ts +7 -2
  240. package/src/modules/workflow/workflow.service.ts +7 -0
  241. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +4 -3
  242. package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +21 -10
  243. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +33 -55
  244. package/src/modules/workspace/__tests__/workspace.tools.test.ts +255 -22
  245. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +231 -1
  246. package/src/modules/workspace/startup/kb-startup-runner.ts +216 -19
  247. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +191 -489
  248. package/src/modules/workspace/startup/steps/seed-tree.ts +21 -27
  249. package/src/modules/workspace/startup/steps/template-files.step.ts +217 -249
  250. package/src/modules/workspace/startup/steps/template-source.ts +5 -3
  251. package/src/modules/workspace/workspace.tools.ts +226 -16
  252. package/src/shared/domain-errors.ts +15 -0
  253. package/src/shared/hidden-tools.ts +45 -0
  254. package/kb-template/AGENTS.md +0 -730
@@ -122,6 +122,46 @@ export interface KbStartupRunnerOptions {
122
122
  * Never throws into the phase, for the same reason as above.
123
123
  */
124
124
  onCloneCreated?: (workspaceId: string) => void;
125
+ /**
126
+ * How long nothing may have been written to an unfinished clone before it
127
+ * is taken for abandoned and set aside. A clone that is still running looks
128
+ * the same on disk, so this is what tells the two apart. Defaults to a
129
+ * minute; a suite passes a short one.
130
+ */
131
+ unfinishedCloneQuietMs?: number;
132
+ }
133
+
134
+ const UNFINISHED_CLONE_QUIET_MS = 60_000;
135
+ /** The floor `kb-git.ts` gives a startup git command, a clone included. */
136
+ const LONGEST_CLONE_MS = 600_000;
137
+ /**
138
+ * How many times one working copy is read before the start gives up on it.
139
+ * A pass ends by acting or by finding the path changed; a path that changes
140
+ * on every one of these is being worked on by something that will not stop.
141
+ */
142
+ const PASSES_OVER_ONE_WORKING_COPY = 4;
143
+
144
+ /** Whether `git clone` failed because its destination already held something, having written nothing. */
145
+ function refusedAnOccupiedFolder(err: unknown): boolean {
146
+ return err instanceof Error && /already exists and is not an empty directory/.test(err.message);
147
+ }
148
+
149
+ /**
150
+ * When anything under `dir` was last written, as a time in milliseconds. Now,
151
+ * for a folder that cannot be read: "could not tell" counts as still in use.
152
+ */
153
+ async function newestChangeUnder(dir: string): Promise<number> {
154
+ let newest = 0;
155
+ try {
156
+ newest = (await fs.stat(dir)).mtimeMs;
157
+ for (const entry of await fs.readdir(dir, { recursive: true, withFileTypes: true })) {
158
+ const stat = await fs.stat(path.join(entry.parentPath, entry.name)).catch(() => null);
159
+ if (stat && stat.mtimeMs > newest) newest = stat.mtimeMs;
160
+ }
161
+ } catch {
162
+ return Date.now();
163
+ }
164
+ return newest;
125
165
  }
126
166
 
127
167
  /**
@@ -678,13 +718,7 @@ export class KbStartupRunner {
678
718
  // Setting it aside already removed it from the workspaces root, so the
679
719
  // cached handle the workspace service holds for it now points at
680
720
  // nothing.
681
- try {
682
- await this.opts.onCloneDiscarded?.(entry.name);
683
- } catch (err) {
684
- startupLog.warn(`could not finish setting the working copy "${entry.name}" aside:`, {
685
- detail: this.redact(err instanceof Error ? err.message : String(err)),
686
- });
687
- }
721
+ await this.announceDiscarded(entry.name);
688
722
  }
689
723
  }
690
724
 
@@ -741,27 +775,168 @@ export class KbStartupRunner {
741
775
  * commit recovery owns that work, not this phase.
742
776
  */
743
777
  private async ensureClone(branch: string): Promise<string> {
778
+ try {
779
+ return await this.cloneOrUpdate(branch);
780
+ } catch (err) {
781
+ // One branch's working copy is what failed, and every boot visits all
782
+ // of them: said here, so the log names the one to look at. The kind of
783
+ // failure git's words were read as is kept, since the setup screen and
784
+ // the degraded start decide by it.
785
+ const message = `the working copy of branch "${branch}" could not be prepared: ${err instanceof Error ? err.message : String(err)}`;
786
+ throw new ClassifiedFailure(message, failureOf(err), { cause: err });
787
+ }
788
+ }
789
+
790
+ /**
791
+ * Decided from what is at the path NOW, and decided again after anything
792
+ * that waited. This phase is not the only thing that touches a working
793
+ * copy: on a running server a branch being opened clones into the same
794
+ * path, and on a redeploy two processes share the volume for a few seconds.
795
+ * So nothing here acts on an answer it read before a wait. Each pass reads
796
+ * the path once and does one thing about it; a pass that found the path
797
+ * changed under it does nothing and lets the next one read it again.
798
+ */
799
+ private async cloneOrUpdate(branch: string): Promise<string> {
744
800
  const workspaceDir = path.join(this.opts.workspacesRoot, workspaceIdForBranch(branch));
745
801
  const repoDir = path.join(workspaceDir, this.opts.kbDirName);
802
+ 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);
805
+ if (pass > PASSES_OVER_ONE_WORKING_COPY) {
806
+ throw new Error('it kept changing while this start was preparing it; something else is working on it');
807
+ }
808
+ if (hasGit) {
809
+ await this.setAsideUnfinishedClone(workspaceIdForBranch(branch), repoDir);
810
+ continue;
811
+ }
812
+ try {
813
+ return await this.cloneFresh(branch, workspaceDir, repoDir);
814
+ } catch (err) {
815
+ // Git refuses to clone into a folder that already holds something,
816
+ // and writes nothing when it does. If that is what happened, somebody
817
+ // else put a clone here between the look above and this one: theirs
818
+ // is read on the next pass. Any other failure is this clone's own.
819
+ if (!refusedAnOccupiedFolder(err)) throw err;
820
+ }
821
+ }
822
+ }
823
+
824
+ /**
825
+ * A `.git` WITH NO COMMIT IS NOT A CLONE. It is what a clone leaves when it
826
+ * is cut short (the process stopped, the disk filled), and it is found
827
+ * again on every start: nothing can fast-forward a branch that has no
828
+ * commit, so the boot failed on it for good, and one such folder kept the
829
+ * whole deployment from starting.
830
+ *
831
+ * It holds no commit, so no committed work of anyone's. It may still hold
832
+ * files somebody put there, so it is MOVED, never deleted, to where every
833
+ * other set-aside working copy goes. The caller clones again.
834
+ *
835
+ * It is also exactly what a clone that is STILL RUNNING looks like: git
836
+ * makes `.git` first and a commit appears only at the end. So it is left
837
+ * alone until nothing has been written to it for a while, and read once
838
+ * more after every wait. Returns having moved it, or having found nothing
839
+ * of the kind left to move.
840
+ */
841
+ private async setAsideUnfinishedClone(id: string, repoDir: string): Promise<void> {
842
+ if (!(await this.waitUntilNothingWritesTo(repoDir))) return;
843
+ await this.opts.beforeCloneSetAside?.(id);
844
+ // The hook above may have waited. What is here now decides.
845
+ if (!(await this.isUnfinishedClone(repoDir))) return;
846
+ const kept = path.join(setAsideRootFor(this.opts.workspacesRoot, this.opts.setAsideRoot), setAsideStamp(), id);
847
+ try {
848
+ await setAsideClone(repoDir, kept);
849
+ } catch (err) {
850
+ // Taken away by somebody else in the instant since it was read: there
851
+ // is nothing left to move, which is not a reason to stop the start.
852
+ // Only a move this call made is logged and announced.
853
+ if (await fs.access(repoDir).then(() => true, () => false)) throw err;
854
+ await fs.rmdir(path.dirname(kept)).catch(() => undefined);
855
+ return;
856
+ }
857
+ startupLog.warn(
858
+ `working copy "${id}" has a git folder and no commit: a clone that was never finished. ` +
859
+ `Set aside at ${kept}; nothing was deleted, and it is cloned again now.`,
860
+ );
861
+ await this.announceDiscarded(id);
862
+ }
863
+
864
+ private async isUnfinishedClone(repoDir: string): Promise<boolean> {
746
865
  const hasGit = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
747
- if (!hasGit) {
748
- await fs.mkdir(workspaceDir, { recursive: true });
749
- await fs.rm(repoDir, { recursive: true, force: true });
750
- await git(this.opts.gitRunner, workspaceDir, ['clone', '-b', branch, this.opts.kbRepoUrl(), repoDir]);
751
- // Said as soon as the clone is there, before its configuration: if a
752
- // command below fails, the clone stays on disk, and the retry finds it
753
- // and never comes back through here.
866
+ return hasGit && !(await this.hasCommit(repoDir));
867
+ }
868
+
869
+ /**
870
+ * Wait until nothing under the unfinished clone's `.git` has been written
871
+ * for {@link KbStartupRunnerOptions.unfinishedCloneQuietMs}. True when it is
872
+ * then still an unfinished clone; false when it finished or went away
873
+ * meanwhile, so there is nothing to set aside.
874
+ *
875
+ * A running clone that has written nothing for that long (a remote still
876
+ * counting objects on a very large repository) is not told apart from an
877
+ * abandoned one. Bounded by the longest a clone may run plus the quiet
878
+ * time: past that, whatever is writing is not a clone.
879
+ */
880
+ private async waitUntilNothingWritesTo(repoDir: string): Promise<boolean> {
881
+ const quietMs = this.opts.unfinishedCloneQuietMs ?? UNFINISHED_CLONE_QUIET_MS;
882
+ const giveUpAt = Date.now() + Math.max(this.opts.gitRunner.defaultTimeoutMs, LONGEST_CLONE_MS) + quietMs;
883
+ for (;;) {
884
+ if (!(await this.isUnfinishedClone(repoDir))) return false;
885
+ const lastWrite = await newestChangeUnder(path.join(repoDir, '.git'));
886
+ const quietFor = Date.now() - lastWrite;
887
+ if (quietFor >= quietMs) return true;
888
+ if (Date.now() > giveUpAt) throw new Error('an unfinished clone at its path is still being written to');
889
+ await new Promise((resolve) => setTimeout(resolve, Math.min(Math.max(quietMs - quietFor, 25), 1_000)));
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Tell the rest of the process a working copy was set aside. The listener
895
+ * releases, a second time, the work queued against the copy that went;
896
+ * skipped, those rows would be committed into the clone that replaces it.
897
+ * So it is tried twice before the failure is only logged. Never throws
898
+ * into the phase: a listener that fails must not stop a boot.
899
+ */
900
+ private async announceDiscarded(id: string): Promise<void> {
901
+ for (let attempt = 1; ; attempt++) {
754
902
  try {
755
- this.opts.onCloneCreated?.(workspaceIdForBranch(branch));
903
+ await this.opts.onCloneDiscarded?.(id);
904
+ return;
756
905
  } catch (err) {
757
- startupLog.warn(`could not announce the fresh working copy for "${branch}":`, {
906
+ if (attempt < 2) continue;
907
+ startupLog.warn(`could not finish setting the working copy "${id}" aside:`, {
758
908
  detail: this.redact(err instanceof Error ? err.message : String(err)),
759
909
  });
910
+ return;
760
911
  }
761
- await git(this.opts.gitRunner, repoDir, ['config', 'core.longpaths', 'true']);
762
- await stampIdentity(this.opts.gitRunner, repoDir);
763
- return repoDir;
764
912
  }
913
+ }
914
+
915
+ private async cloneFresh(branch: string, workspaceDir: string, repoDir: string): Promise<string> {
916
+ 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
919
+ // refuses it below rather than this removing it.
920
+ if (!(await fs.access(path.join(repoDir, '.git')).then(() => true, () => false))) {
921
+ await fs.rm(repoDir, { recursive: true, force: true });
922
+ }
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.
927
+ try {
928
+ this.opts.onCloneCreated?.(workspaceIdForBranch(branch));
929
+ } catch (err) {
930
+ startupLog.warn(`could not announce the fresh working copy for "${branch}":`, {
931
+ detail: this.redact(err instanceof Error ? err.message : String(err)),
932
+ });
933
+ }
934
+ await git(this.opts.gitRunner, repoDir, ['config', 'core.longpaths', 'true']);
935
+ await stampIdentity(this.opts.gitRunner, repoDir);
936
+ return repoDir;
937
+ }
938
+
939
+ private async updateClone(branch: string, repoDir: string): Promise<string> {
765
940
  await git(this.opts.gitRunner, repoDir, ['fetch', 'origin', branch]);
766
941
  const local = (await git(this.opts.gitRunner, repoDir, ['rev-parse', 'HEAD'])).trim();
767
942
  const remote = (await git(this.opts.gitRunner, repoDir, ['rev-parse', `origin/${branch}`])).trim();
@@ -775,6 +950,28 @@ export class KbStartupRunner {
775
950
  return repoDir;
776
951
  }
777
952
 
953
+ /**
954
+ * Whether the repository at `repoDir` has a commit checked out. False for a
955
+ * clone that was cut short.
956
+ *
957
+ * False ONLY when git itself answered that there is none: with `--quiet
958
+ * --verify` that is exit status 1 and nothing else. A git that timed out,
959
+ * could not be started, or could not read the repository has not said the
960
+ * working copy is empty, and "could not tell" is no reason to move
961
+ * someone's work: that failure is thrown, and stops the start with the
962
+ * branch named.
963
+ */
964
+ private async hasCommit(repoDir: string): Promise<boolean> {
965
+ try {
966
+ await git(this.opts.gitRunner, repoDir, ['rev-parse', '--quiet', '--verify', 'HEAD^{commit}']);
967
+ return true;
968
+ } catch (err) {
969
+ const ran = err instanceof Error ? err.cause : undefined;
970
+ if (ran instanceof GitRunError && !ran.timedOut && ran.exitCode === 1) return false;
971
+ throw err;
972
+ }
973
+ }
974
+
778
975
  /** One commit per dirty branch; push; the replica carve-out on rejection. */
779
976
  private async finalize(h: BranchHandle): Promise<void> {
780
977
  if (!h.dirty) return;