@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
@@ -14,7 +14,8 @@ import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
14
14
  import { ToolError, type ToolContext } from '../../tool-helpers/tool.contract.js';
15
15
  import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
16
16
  import { registerWorkspaceTools } from '../workspace.tools.js';
17
- import { sharedFileRules, sharedFileRulesSection, sharedRulesPointer } from '../../agent-instructions/shared-file-rules.js';
17
+ import { sharedFileRules, sharedFileRulesSection } from '../../agent-instructions/shared-file-rules.js';
18
+ import { GUIDE_FIRST_SENTENCE } from '../../tool-registry/guide-first.js';
18
19
  import { RoutineWritePolicyService } from '../routine-write-policy.js';
19
20
  import { UuidSessionSink, type ISessionSink } from '../session-sink.js';
20
21
  import { WorkflowHooks, type AgentOperationContext } from '../../workflow/workflow-hooks.js';
@@ -98,6 +99,8 @@ let unzipEntries: string[] = [];
98
99
  let folderTurns: string[] = [];
99
100
  /** The policy instance the tools were mounted with, so a test can restrict a session. */
100
101
  let writePolicy: RoutineWritePolicyService;
102
+ /** What the platform's guide reads as, for the tests about the guide's name. */
103
+ let guideText = 'THE PLATFORM GUIDE\n';
101
104
  /**
102
105
  * The focused branch the resolved `ToolContext` carries — mirrors the branch an
103
106
  * internal token bakes for the in-process agent. A test sets it to prove a
@@ -297,7 +300,7 @@ async function start(
297
300
  recoveryBotEmail: RECOVERY_BOT,
298
301
  hooks,
299
302
  notes,
300
- }, writePolicy, {} as never /* sessionSink — start_session not exercised here */);
303
+ }, writePolicy, {} as never /* sessionSink — start_session not exercised here */, undefined, undefined, undefined, async () => guideText);
301
304
  app.use('/api', router);
302
305
  httpServer = await new Promise<HttpServer>((r) => {
303
306
  const s = app.listen(0, () => r(s));
@@ -1096,6 +1099,193 @@ describe('write modes and per-path outcomes', () => {
1096
1099
  });
1097
1100
  });
1098
1101
 
1102
+ /**
1103
+ * The agent guide at the repository root. It is not a file: `read_file` of
1104
+ * its name answers with the platform's guide, after the knowledge base's own
1105
+ * file of that name when it has one, and `file_stat` says what is there.
1106
+ */
1107
+ describe("the agent guide at the guide's name", () => {
1108
+ const GUIDE = `${KB_DIR}/AGENTS.md`;
1109
+ const read = (base: string, p = GUIDE, extra: Record<string, unknown> = {}) =>
1110
+ post(`${base}/api/agent/tools/read_file`, { path: p, ...extra }).then((r) => r.json() as Promise<{ path: string; content: string }>);
1111
+ const statOf = (base: string, p = GUIDE) =>
1112
+ post(`${base}/api/agent/tools/file_stat`, { path: p }).then((r) => r.json() as Promise<Record<string, unknown>>);
1113
+
1114
+ it('answers with the guide when the knowledge base has no file of that name', async () => {
1115
+ guideText = 'THE PLATFORM GUIDE\n';
1116
+ const base = await start();
1117
+ expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n' });
1118
+ // By the root-anchored and the prefix-less spellings too, like any path.
1119
+ expect((await read(base, `/${GUIDE}`)).content).toBe('THE PLATFORM GUIDE\n');
1120
+ expect((await read(base, 'AGENTS.md')).content).toBe('THE PLATFORM GUIDE\n');
1121
+ // Sliced like any content.
1122
+ expect((await read(base, GUIDE, { offset: 4, limit: 8 })).content).toBe('PLATFORM');
1123
+ });
1124
+
1125
+ it("puts the knowledge base's own AGENTS.md first, then the separator, then the guide", async () => {
1126
+ guideText = 'THE PLATFORM GUIDE\n';
1127
+ const base = await start();
1128
+ await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n\nWrite tickets in the present tense.\n');
1129
+ const { content } = await read(base);
1130
+ expect(content.startsWith('# Acme\n\nWrite tickets in the present tense.\n\n---\n')).toBe(true);
1131
+ expect(content.endsWith('\n\nTHE PLATFORM GUIDE\n')).toBe(true);
1132
+ expect(content).toContain("The text above is this knowledge base's own conventions file.");
1133
+ });
1134
+
1135
+ it('never serves a copy of the guide an earlier release left on disk a second time', async () => {
1136
+ guideText = 'THE PLATFORM GUIDE\n';
1137
+ const base = await start();
1138
+ await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Knowledge base\n\n> **This file is managed by the platform.** Stale.\n');
1139
+ expect((await read(base)).content).toBe('THE PLATFORM GUIDE\n');
1140
+ });
1141
+
1142
+ it("never tells a caller who may not read the knowledge base's own file that it exists", async () => {
1143
+ guideText = 'THE PLATFORM GUIDE\n';
1144
+ const denied = await start('read', denyReads(new Set(['AGENTS.md'])));
1145
+ // Nothing of theirs there: the guide is everyone's.
1146
+ const absent = await read(denied);
1147
+ const absentStat = await statOf(denied);
1148
+ expect(absent.content).toBe('THE PLATFORM GUIDE\n');
1149
+ // A file they may not read answers EXACTLY as no file does — a refusal
1150
+ // would be the one thing the platform never says about a restricted
1151
+ // file, which is that it is there.
1152
+ await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n\nThe secret conventions.\n');
1153
+ const closed = await post(`${denied}/api/agent/tools/read_file`, { path: GUIDE });
1154
+ expect(closed.status).toBe(200);
1155
+ expect(await closed.json()).toEqual(absent);
1156
+ expect(await statOf(denied)).toEqual(absentStat);
1157
+ });
1158
+
1159
+ it("serves the knowledge base's own file to a caller who may read it", async () => {
1160
+ guideText = 'THE PLATFORM GUIDE\n';
1161
+ const allowed = await start('read');
1162
+ await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n\nThe conventions.\n');
1163
+ expect((await read(allowed)).content).toContain('The conventions.');
1164
+ });
1165
+
1166
+ it("answers the guide alone when the knowledge base's own file vanishes between the probe and the read", async () => {
1167
+ guideText = 'THE PLATFORM GUIDE\n';
1168
+ const base = await start();
1169
+ // The file is there when it is probed and gone when it is read: a
1170
+ // concurrent delete, which is the absent case and never a failure.
1171
+ const probed = fs.stat.bind(fs);
1172
+ let vanish = false;
1173
+ (fs as unknown as Record<string, unknown>).stat = async (p: string) => {
1174
+ const st = await probed(p);
1175
+ if (vanish && p.endsWith('AGENTS.md')) {
1176
+ vanish = false;
1177
+ await fs.deleteFile(p);
1178
+ }
1179
+ return st;
1180
+ };
1181
+ await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n');
1182
+ vanish = true;
1183
+ expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n' });
1184
+ await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n');
1185
+ vanish = true;
1186
+ expect(await statOf(base)).toMatchObject({ platformGuide: true });
1187
+ });
1188
+
1189
+ it('leaves a folder at the guide\'s name to the ordinary stat, and never reads it as a copy', async () => {
1190
+ guideText = 'THE PLATFORM GUIDE\n';
1191
+ const base = await start();
1192
+ await fs.mkdir(`${KB_DIR}/AGENTS.md`);
1193
+ const folder = await statOf(base);
1194
+ expect(folder).toMatchObject({ type: 'directory' });
1195
+ expect(folder.platformGuide).toBeUndefined();
1196
+ });
1197
+
1198
+ it('is a nested AGENTS.md no concern of: that is a file like any other', async () => {
1199
+ guideText = 'THE PLATFORM GUIDE\n';
1200
+ const base = await start();
1201
+ await fs.writeFile(`${KB_DIR}/Handbook/AGENTS.md`, '# Handbook\n');
1202
+ expect((await read(base, `${KB_DIR}/Handbook/AGENTS.md`)).content).toBe('# Handbook\n');
1203
+ expect((await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/Handbook/HEXIS.md` })).status).toBe(404);
1204
+ });
1205
+
1206
+ it('grep finds the guide where read_file serves it: from the root and at its own path, own file first, denied file absent', async () => {
1207
+ guideText = '# Guide\n\nName every needle you plant.\n';
1208
+ const grep = (base: string, pattern: string, path?: string) =>
1209
+ post(`${base}/api/agent/tools/grep`, path === undefined ? { pattern } : { pattern, path }).then(
1210
+ (r) => r.json() as Promise<{ matches: { path: string; line: number; text: string }[] }>,
1211
+ );
1212
+ const base = await start();
1213
+ await fs.writeFile(`${KB_DIR}/Handbook/a.md`, 'a needle on disk\n');
1214
+ // A search of the whole knowledge base reaches the guide, under the path
1215
+ // a read of it answers to, with the line number that read gives.
1216
+ const fromRoot = await grep(base, 'needle');
1217
+ expect(fromRoot.matches).toContainEqual({ path: GUIDE, line: 3, text: 'Name every needle you plant.' });
1218
+ expect(fromRoot.matches).toContainEqual({ path: `${KB_DIR}/Handbook/a.md`, line: 1, text: 'a needle on disk' });
1219
+ // A search of the guide's own path is a search of what read_file answers.
1220
+ expect((await grep(base, 'needle', GUIDE)).matches).toEqual([{ path: GUIDE, line: 3, text: 'Name every needle you plant.' }]);
1221
+ expect((await grep(base, 'needle', 'AGENTS.md')).matches).toEqual([{ path: GUIDE, line: 3, text: 'Name every needle you plant.' }]);
1222
+ // A search under a folder does not reach a file that is not under it.
1223
+ expect((await grep(base, 'needle', `${KB_DIR}/Handbook`)).matches.map((m) => m.path)).toEqual([`${KB_DIR}/Handbook/a.md`]);
1224
+
1225
+ // With the knowledge base's own AGENTS.md, the search covers the composed
1226
+ // text — the own file first, then the guide — and once: the walk's own
1227
+ // matches in that file are the same lines, so they are not repeated.
1228
+ await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n\nOur needle rule.\n');
1229
+ const { content } = await read(base);
1230
+ const guideLine = content.split('\n').indexOf('Name every needle you plant.') + 1;
1231
+ const composed = (await grep(base, 'needle')).matches.filter((m) => m.path === GUIDE);
1232
+ expect(composed).toEqual([
1233
+ { path: GUIDE, line: 3, text: 'Our needle rule.' },
1234
+ { path: GUIDE, line: guideLine, text: 'Name every needle you plant.' },
1235
+ ]);
1236
+ expect((await grep(base, 'needle', GUIDE)).matches).toEqual(composed);
1237
+
1238
+ // The own file does not use up `max_results` twice: the walk leaves it to
1239
+ // the composed search, so a file later in the tree is still reached when
1240
+ // the own file alone has more matches than the cap. Without that, the
1241
+ // walk filled the cap from AGENTS.md, those matches were dropped as
1242
+ // duplicates, and Handbook/ was never searched.
1243
+ await fs.writeFile(`${KB_DIR}/AGENTS.md`, `# Acme\n\n${'needle\n'.repeat(5)}`);
1244
+ const capped = (await (await post(`${base}/api/agent/tools/grep`, { pattern: 'needle', max_results: 3 })).json()) as {
1245
+ matches: { path: string }[];
1246
+ };
1247
+ expect(capped.matches.map((m) => m.path)).toContain(`${KB_DIR}/Handbook/a.md`);
1248
+ expect(capped.matches).toHaveLength(3);
1249
+ await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n\nOur needle rule.\n');
1250
+
1251
+ // A caller who may not read the own file searches the guide alone, from
1252
+ // the root and at the path — as read_file answers them, with no sign that
1253
+ // anything of the organisation's is there.
1254
+ const denied = await start('read', denyReads(new Set(['AGENTS.md'])));
1255
+ await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n\nOur needle rule.\n');
1256
+ for (const found of [await grep(denied, 'needle'), await grep(denied, 'needle', GUIDE)]) {
1257
+ expect(found.matches.filter((m) => m.path === GUIDE)).toEqual([{ path: GUIDE, line: 3, text: 'Name every needle you plant.' }]);
1258
+ expect(JSON.stringify(found)).not.toContain('Acme');
1259
+ }
1260
+ });
1261
+
1262
+ it('file_stat says a text file is there to read, and that nothing can be written, moved or deleted at it', async () => {
1263
+ guideText = 'THE PLATFORM GUIDE\n';
1264
+ const base = await start();
1265
+ expect(await statOf(base)).toMatchObject({
1266
+ name: 'AGENTS.md',
1267
+ type: 'file',
1268
+ size: Buffer.byteLength('THE PLATFORM GUIDE\n'),
1269
+ platformGuide: true,
1270
+ managed: true,
1271
+ movable: false,
1272
+ deletable: false,
1273
+ contentMode: 'text',
1274
+ textEditable: false,
1275
+ access: { read: true, write: false },
1276
+ });
1277
+ // With a file of the knowledge base's own there, stat describes THAT file.
1278
+ await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n');
1279
+ const own = await statOf(base);
1280
+ expect(own).toMatchObject({ name: 'AGENTS.md', type: 'file', managed: false, movable: true, textEditable: true });
1281
+ expect(own.platformGuide).toBeUndefined();
1282
+ // A copy an earlier release wrote is what read_file does not serve, so
1283
+ // stat says the same thing it says for no file at all.
1284
+ await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Knowledge base\n\n> **This file is managed by the platform.** Stale.\n');
1285
+ expect(await statOf(base)).toMatchObject({ platformGuide: true, managed: true, movable: false });
1286
+ });
1287
+ });
1288
+
1099
1289
  describe('read-permission gating', () => {
1100
1290
  // Seed two KB nodes; the access stub denies read on the "secret" one.
1101
1291
  async function startGated(): Promise<string> {
@@ -1714,19 +1904,18 @@ describe('office documents and PDFs', () => {
1714
1904
  expect(await readContent(base, `${KB_DIR}/deck.pptx`)).toContain('Original');
1715
1905
  });
1716
1906
 
1717
- it('every mounted tool ends with the one sentence pointing at the shared rules, and repeats none of them', async () => {
1907
+ it('every mounted tool opens with the one sentence sending the agent to the guide, and repeats none of the rules', async () => {
1718
1908
  await start();
1719
1909
  const tools = await toolRegistry.listInternal();
1720
- const pointer = sharedRulesPointer(testKbContext().layout);
1721
1910
  // The shell is in the list too: it carried the agent-guide reminder before,
1722
1911
  // and that reminder is one of the rules that moved.
1723
1912
  const mounted = ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'delete_folder', 'mkdir', 'move_file', 'copy_file', 'unzip', 'execute_command'];
1724
1913
  for (const name of mounted) {
1725
1914
  const def = tools.find((t) => t.name === name);
1726
1915
  expect(def, name).toBeDefined();
1727
- expect(def!.description!.endsWith(pointer), name).toBe(true);
1728
- // Once, at the end — not once per paragraph that used to be appended.
1729
- expect(def!.description!.split(pointer), name).toHaveLength(2);
1916
+ expect(def!.description!.startsWith(`${GUIDE_FIRST_SENTENCE} `), name).toBe(true);
1917
+ // Once, at the front — not once per paragraph that used to be appended.
1918
+ expect(def!.description!.split(GUIDE_FIRST_SENTENCE), name).toHaveLength(2);
1730
1919
  // EVERY shared rule, in full, is in the two shared places now (see
1731
1920
  // agent-instructions/__tests__/shared-file-rules.test.ts) and in no
1732
1921
  // description. Checked on the whole body rather than on a phrase: the
@@ -1740,10 +1929,11 @@ describe('office documents and PDFs', () => {
1740
1929
  expect(def!.description, name).not.toContain('Content rule (the same on every file tool)');
1741
1930
  expect(def!.description, name).not.toContain('Before your first read or change in a workspace');
1742
1931
  }
1743
- // start_session carried none of the shared paragraphs and gains no pointer:
1744
- // it touches no file. External-only, so it is looked up on that surface.
1932
+ // start_session touches no file, yet it opens with the same sentence: the
1933
+ // guide is read before ANYTHING in the platform, a session included.
1934
+ // External-only, so it is looked up on that surface.
1745
1935
  const external = await toolRegistry.listExternal();
1746
- expect(external.find((t) => t.name === 'start_session')!.description).not.toContain(pointer);
1936
+ expect(external.find((t) => t.name === 'start_session')!.description!.startsWith(`${GUIDE_FIRST_SENTENCE} `)).toBe(true);
1747
1937
  });
1748
1938
 
1749
1939
  describe('binary capability contract: a text file, a document, an image and a zip', () => {
@@ -2998,19 +3188,17 @@ describe('preflight for moves and deletes', () => {
2998
3188
  expect(await exists(args.src)).toBe(true);
2999
3189
  });
3000
3190
 
3001
- it('every one of the four platform files gets the same sentence, and the agent never gets the admin restore', async () => {
3191
+ it('every one of the three platform files gets the same sentence, and the agent never gets the admin restore', async () => {
3002
3192
  // The agent move tool has no exception: the recovery move is a person's,
3003
3193
  // made as an admin, and an agent is neither.
3004
3194
  const base = await seeded();
3005
3195
  await fs.writeFile(KB('roles.yaml'), 'roles: {}\n');
3006
- await fs.writeFile(KB('AGENTS.md'), 'agents\n');
3007
3196
  await fs.writeFile(KB('.bevelignore'), '*.tmp\n');
3008
3197
  await fs.writeFile(KB('Misplaced/access.md'), '---\nread: everyone\n---\n');
3009
3198
  const cases: [string, string][] = [
3010
3199
  [KB('access.md'), KB('Sales/access.md')],
3011
3200
  [KB('roles.yaml'), KB('Sales/roles.yaml')],
3012
3201
  [KB('.bevelignore'), KB('Sales/.bevelignore')],
3013
- [KB('AGENTS.md'), KB('Sales/AGENTS.md')],
3014
3202
  // Including the shape of the admin's recovery move: a misplaced
3015
3203
  // access.md into a folder that has none. A person holding the Admin
3016
3204
  // role is allowed exactly this move from the UI; the agent is not.
@@ -3120,15 +3308,19 @@ describe('preflight for moves and deletes', () => {
3120
3308
  describe('what counts as the platform\'s own, and what a move or delete may reach', () => {
3121
3309
  const caseSensitiveDisk = process.platform === 'linux';
3122
3310
 
3123
- it('roles.yaml and AGENTS.md are platform files only at the root; access.md and .bevelignore at any depth', async () => {
3311
+ it('roles.yaml is a platform file only at the root; access.md and .bevelignore at any depth; AGENTS.md nowhere', async () => {
3124
3312
  const base = await seeded();
3125
3313
  await fs.writeFile(KB('roles.yaml'), 'roles: {}\n');
3126
3314
  await fs.writeFile(KB('Sales/roles.yaml'), 'content');
3315
+ await fs.writeFile(KB('AGENTS.md'), 'content');
3127
3316
  await fs.writeFile(KB('Sales/AGENTS.md'), 'content');
3128
3317
  await fs.writeFile(KB('Sales/.bevelignore'), '*.tmp\n');
3129
3318
  const managed = async (p: string) => (await call(base, 'file_stat', { path: KB(p) })).body.managed;
3130
3319
  expect(await managed('roles.yaml')).toBe(true);
3131
3320
  expect(await managed('Sales/roles.yaml')).toBe(false);
3321
+ // The organisation's own conventions file: the guide is served from
3322
+ // code, so nothing under this name is the platform's.
3323
+ expect(await managed('AGENTS.md')).toBe(false);
3132
3324
  expect(await managed('Sales/AGENTS.md')).toBe(false);
3133
3325
  expect(await managed('Sales/.bevelignore')).toBe(true);
3134
3326
  expect((await call(base, 'move_file', { src: KB('Sales/roles.yaml'), dest: KB('Sales/old-roles.yaml') })).body).toMatchObject({ moved: true });
@@ -3497,7 +3689,7 @@ describe('preflight for moves and deletes', () => {
3497
3689
  // is stated once in the shared rules rather than on each of them.
3498
3690
  expect(sharedFileRulesSection(testKbContext().layout)).toContain('`write-denied`');
3499
3691
  for (const name of ['move_file', 'delete_file', 'delete_folder']) {
3500
- expect((await def(name)).description, name).toContain(sharedRulesPointer(testKbContext().layout));
3692
+ expect((await def(name)).description.startsWith(GUIDE_FIRST_SENTENCE), name).toBe(true);
3501
3693
  }
3502
3694
  });
3503
3695
  });
@@ -4015,6 +4207,47 @@ describe('agent read/write hooks', () => {
4015
4207
  expect(writes).toEqual([]);
4016
4208
  });
4017
4209
 
4210
+ it("file_stat at the guide's name tells the read hook once when it reads the organisation's own file, and never for the guide alone", async () => {
4211
+ guideText = 'THE PLATFORM GUIDE\n';
4212
+ const base = await start();
4213
+ record();
4214
+ // Nothing of the organisation's there: the guide is everyone's and no
4215
+ // file is read, so the hook hears nothing.
4216
+ expect((await post(`${base}/api/agent/tools/file_stat`, { path: `${KB_DIR}/AGENTS.md`, sessionId: 's1' })).status).toBe(200);
4217
+ expect(reads).toEqual([]);
4218
+ // The organisation's own file: telling it from a stale copy reads it,
4219
+ // and the hook hears of that read exactly once — as it does of a read_file.
4220
+ await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n');
4221
+ expect((await post(`${base}/api/agent/tools/file_stat`, { path: `${KB_DIR}/AGENTS.md`, sessionId: 's1' })).status).toBe(200);
4222
+ expect(reads.map((op) => op.wsPath)).toEqual([`${KB_DIR}/AGENTS.md`]);
4223
+ // A stale copy of the guide is read to be recognised, so it is heard of too.
4224
+ await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Knowledge base\n\n> **This file is managed by the platform.** Stale.\n');
4225
+ expect((await post(`${base}/api/agent/tools/file_stat`, { path: `${KB_DIR}/AGENTS.md`, sessionId: 's1' })).status).toBe(200);
4226
+ expect(reads.map((op) => op.wsPath)).toEqual([`${KB_DIR}/AGENTS.md`, `${KB_DIR}/AGENTS.md`]);
4227
+ });
4228
+
4229
+ it("grep at the guide's name tells the read hook the same way: never for the guide alone, once for the organisation's own file", async () => {
4230
+ guideText = 'THE PLATFORM GUIDE\n';
4231
+ const base = await start();
4232
+ record();
4233
+ // The guide alone: nothing of the organisation's is read, so the hook
4234
+ // hears nothing — a search of the guide's path is not a read of a file.
4235
+ expect((await post(`${base}/api/agent/tools/grep`, { pattern: 'GUIDE', path: `${KB_DIR}/AGENTS.md`, sessionId: 's1' })).status).toBe(200);
4236
+ expect(reads).toEqual([]);
4237
+ // The organisation's own file: once, as read_file tells it — not once
4238
+ // for the search root and again for the file.
4239
+ await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme GUIDE\n');
4240
+ expect((await post(`${base}/api/agent/tools/grep`, { pattern: 'GUIDE', path: `${KB_DIR}/AGENTS.md`, sessionId: 's1' })).status).toBe(200);
4241
+ expect(reads.map((op) => op.wsPath)).toEqual([`${KB_DIR}/AGENTS.md`]);
4242
+ // A search of the whole knowledge base: the root once, then each file the
4243
+ // walk opens once — the own AGENTS.md among them exactly once, from the
4244
+ // composed search, never again from the walk.
4245
+ reads = [];
4246
+ expect((await post(`${base}/api/agent/tools/grep`, { pattern: 'GUIDE', sessionId: 's1' })).status).toBe(200);
4247
+ expect(reads[0]?.wsPath).toBe(KB_DIR);
4248
+ expect(reads.filter((op) => op.wsPath === `${KB_DIR}/AGENTS.md`)).toHaveLength(1);
4249
+ });
4250
+
4018
4251
  it('the read hook covers list_files, file_stat, grep, delete_file and delete_folder', async () => {
4019
4252
  const base = await start();
4020
4253
  record();
@@ -4275,22 +4508,22 @@ describe('tool descriptions and the deployment note', () => {
4275
4508
  expect(all.get('read_file')!.description).not.toContain('Stay within one');
4276
4509
  });
4277
4510
 
4278
- it("a registered note lands after every gated tool's own text, ahead of the shared-rules pointer, and on the sessionId input", async () => {
4511
+ it("a registered note lands after every gated tool's own text, behind the guide-first opening, and on the sessionId input", async () => {
4279
4512
  await start();
4280
4513
  notes.registerGatedToolNote(' One folder per conversation.');
4281
4514
  notes.registerSessionIdNote(' It also pins that folder.');
4282
4515
  const all = await defs();
4283
- // The POINTER is last, always: that is the one sentence an agent needs to
4284
- // find the shared rules, and a description cut short must not lose it. The
4285
- // deployment's note sits directly before it, after the tool's own text.
4286
- const pointer = sharedRulesPointer(testKbContext().layout);
4516
+ // The guide-first sentence is FIRST, always: that is the one instruction
4517
+ // an agent needs, and a description cut short from the end must not lose
4518
+ // it. The deployment's note is last, after the tool's own text.
4287
4519
  for (const name of ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'delete_folder', 'mkdir', 'move_file', 'copy_file', 'unzip']) {
4288
- expect(all.get(name)!.description.endsWith(` One folder per conversation.${pointer}`), name).toBe(true);
4520
+ expect(all.get(name)!.description.startsWith(`${GUIDE_FIRST_SENTENCE} `), name).toBe(true);
4521
+ expect(all.get(name)!.description.endsWith(' One folder per conversation.'), name).toBe(true);
4289
4522
  expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(`${SESSION_ID_DESCRIPTION} It also pins that folder.`);
4290
4523
  }
4291
4524
  // `execute_command` is internal-only, so it is checked on that surface.
4292
4525
  const internal = new Map((await toolRegistry.listInternal()).map((t) => [t.name, t]));
4293
- expect(internal.get('execute_command')!.description.endsWith(` One folder per conversation.${pointer}`)).toBe(true);
4526
+ expect(internal.get('execute_command')!.description.endsWith(' One folder per conversation.')).toBe(true);
4294
4527
  });
4295
4528
 
4296
4529
  it('a tool that is not gated carries no note', async () => {
@@ -10,7 +10,7 @@ import { KbStartupRunner } from '../kb-startup-runner.js';
10
10
  import { WorkspaceService } from '../../workspace.service.js';
11
11
  import { NodeGitRunner } from '../../../workflow/git/node-git-runner.js';
12
12
  import { ClassifiedFailure, classifyGitFailure, failureOf } from '../../../../shared/git-failure.js';
13
- import { gitCredentials } from '../../../../shared/git.contract.js';
13
+ import { GitRunError, gitCredentials } from '../../../../shared/git.contract.js';
14
14
  import type { OnServerStart, ServerStartContext, StepResult } from '../on-server-start.js';
15
15
 
16
16
  /**
@@ -85,6 +85,8 @@ function runnerOpts(steps: OnServerStart[], overrides: Record<string, unknown> =
85
85
  defaultBranch: () => DEFAULT_BRANCH,
86
86
  protectedBranches: () => PROTECTED,
87
87
  seedAdminEmails: ['admin@example.com'],
88
+ // An unfinished clone made by a suite is abandoned the moment it exists.
89
+ unfinishedCloneQuietMs: 0,
88
90
  steps,
89
91
  buildSeedTree: async (dir: string) => {
90
92
  await fs.writeFile(path.join(dir, 'seeded.md'), 'from template', 'utf8');
@@ -784,6 +786,234 @@ describe('KbStartupRunner', () => {
784
786
  });
785
787
  });
786
788
 
789
+ /**
790
+ * A clone that was cut short leaves a `.git` folder and no commit. Every
791
+ * start found it again, could do nothing with a branch that has no commit,
792
+ * and stopped: one such folder kept a whole deployment from starting.
793
+ */
794
+ describe('KbStartupRunner — a working copy whose clone was never finished', () => {
795
+ const touchDefault = step('touch', async (ctx) => {
796
+ await (await ctx.defaultBranch()).repoDir();
797
+ return { outcome: 'ok' };
798
+ });
799
+ const local = () => path.join(workspacesRoot, DEFAULT_BRANCH, 'knowledge-base');
800
+
801
+ /** What an interrupted clone leaves: the repository made, its remote set, nothing checked out. */
802
+ async function halfMadeClone(): Promise<void> {
803
+ await fs.mkdir(local(), { recursive: true });
804
+ await git(local(), ['init', '-b', DEFAULT_BRANCH]);
805
+ await git(local(), ['remote', 'add', 'origin', upstream]);
806
+ await fs.writeFile(path.join(local(), 'left-here.txt'), 'somebody put this here', 'utf8');
807
+ }
808
+
809
+ it('is set aside, files and all, and cloned again, so the start goes through', async () => {
810
+ await populatedUpstream();
811
+ await halfMadeClone();
812
+ const discarded: string[] = [];
813
+
814
+ await makeRunner([touchDefault], { onCloneDiscarded: (id: string) => void discarded.push(id) }).runAll();
815
+
816
+ // A real clone stands in its place.
817
+ expect(await fs.readFile(path.join(local(), 'marker.txt'), 'utf8')).toBe('seeded');
818
+ expect((await git(local(), ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()).toBe(DEFAULT_BRANCH);
819
+ // Nothing was deleted: what was in the folder is where set-aside copies go.
820
+ const runs = await fs.readdir(path.join(root, 'set-aside'));
821
+ expect(runs).toHaveLength(1);
822
+ const kept = path.join(root, 'set-aside', runs[0]!, DEFAULT_BRANCH);
823
+ expect(await fs.readFile(path.join(kept, 'left-here.txt'), 'utf8')).toBe('somebody put this here');
824
+ expect(discarded).toEqual([DEFAULT_BRANCH]);
825
+
826
+ // And the next start finds an ordinary clone: nothing more is set aside.
827
+ await makeRunner([touchDefault]).runAll();
828
+ expect(await fs.readdir(path.join(root, 'set-aside'))).toHaveLength(1);
829
+ });
830
+
831
+ it('leaves a working copy that has a commit exactly where it is', async () => {
832
+ await populatedUpstream();
833
+ await makeRunner([touchDefault]).runAll();
834
+ await fs.writeFile(path.join(local(), 'unpushed.md'), 'precious', 'utf8');
835
+ await git(local(), ['add', '-A']);
836
+ await git(local(), ['commit', '-m', 'committed but unpushed']);
837
+
838
+ await makeRunner([touchDefault]).runAll();
839
+
840
+ expect(await fs.readFile(path.join(local(), 'unpushed.md'), 'utf8')).toBe('precious');
841
+ expect(await fs.access(path.join(root, 'set-aside')).then(() => true, () => false)).toBe(false);
842
+ });
843
+
844
+ it('leaves a working copy alone when git could not say whether it has a commit', async () => {
845
+ await populatedUpstream();
846
+ await makeRunner([touchDefault]).runAll();
847
+ await fs.writeFile(path.join(local(), 'unpushed.md'), 'precious', 'utf8');
848
+ await git(local(), ['add', '-A']);
849
+ await git(local(), ['commit', '-m', 'committed but unpushed']);
850
+ // Git gives no answer to the one question: the deadline passes, as it
851
+ // does on a host under load. Everything else runs for real.
852
+ const real = new NodeGitRunner(undefined, credentials);
853
+ const gitRunner = {
854
+ defaultTimeoutMs: real.defaultTimeoutMs,
855
+ credentials: real.credentials,
856
+ run: (cwd: string, args: string[], opts?: object) =>
857
+ args.includes('--verify')
858
+ ? Promise.reject(new GitRunError('git rev-parse timed out', { timedOut: true }))
859
+ : real.run(cwd, args, opts),
860
+ };
861
+
862
+ const err = await makeRunner([touchDefault], { gitRunner })
863
+ .runAll()
864
+ .then(
865
+ () => null,
866
+ (e: unknown) => e as Error,
867
+ );
868
+
869
+ // "Could not tell" is not "has no commit": the start stops, the branch is
870
+ // named, and the work is where it was.
871
+ expect(err?.message).toContain(`the working copy of branch "${DEFAULT_BRANCH}" could not be prepared`);
872
+ expect(await fs.readFile(path.join(local(), 'unpushed.md'), 'utf8')).toBe('precious');
873
+ expect(await fs.access(path.join(root, 'set-aside')).then(() => true, () => false)).toBe(false);
874
+ });
875
+
876
+ // On a redeploy two processes share the volume for a few seconds. What the
877
+ // other one did while this one waited is what decides.
878
+ it('does not move a clone another process put in its place while this one waited', async () => {
879
+ await populatedUpstream();
880
+ await halfMadeClone();
881
+ const beforeCloneSetAside = async () => {
882
+ await fs.rm(local(), { recursive: true, force: true });
883
+ await git(root, ['clone', '-b', DEFAULT_BRANCH, upstream, local()]);
884
+ await fs.writeFile(path.join(local(), 'theirs.txt'), 'the other process wrote this', 'utf8');
885
+ };
886
+
887
+ await makeRunner([touchDefault], { beforeCloneSetAside }).runAll();
888
+
889
+ expect(await fs.readFile(path.join(local(), 'theirs.txt'), 'utf8')).toBe('the other process wrote this');
890
+ expect(await fs.access(path.join(root, 'set-aside')).then(() => true, () => false)).toBe(false);
891
+ });
892
+
893
+ it('clones when another process moved the half-made clone away while this one waited', async () => {
894
+ await populatedUpstream();
895
+ await halfMadeClone();
896
+ const beforeCloneSetAside = () => fs.rm(local(), { recursive: true, force: true });
897
+
898
+ await makeRunner([touchDefault], { beforeCloneSetAside }).runAll();
899
+
900
+ expect(await fs.readFile(path.join(local(), 'marker.txt'), 'utf8')).toBe('seeded');
901
+ expect(await fs.access(path.join(root, 'set-aside')).then(() => true, () => false)).toBe(false);
902
+ });
903
+
904
+ it('carries on when the half-made clone is taken away in the instant before the move', async () => {
905
+ await populatedUpstream();
906
+ await halfMadeClone();
907
+ // The folder goes right after git has answered, for the last time before
908
+ // the move, that it holds no commit.
909
+ const real = new NodeGitRunner(undefined, credentials);
910
+ let asked = 0;
911
+ const gitRunner = {
912
+ defaultTimeoutMs: real.defaultTimeoutMs,
913
+ credentials: real.credentials,
914
+ run: async (cwd: string, args: string[], opts?: object) => {
915
+ if (!args.includes('--verify') || ++asked !== 3) return real.run(cwd, args, opts);
916
+ try {
917
+ return await real.run(cwd, args, opts);
918
+ } finally {
919
+ await fs.rm(local(), { recursive: true, force: true });
920
+ }
921
+ },
922
+ };
923
+ const discarded: string[] = [];
924
+
925
+ await makeRunner([touchDefault], { gitRunner, onCloneDiscarded: (id: string) => void discarded.push(id) }).runAll();
926
+
927
+ expect(await fs.readFile(path.join(local(), 'marker.txt'), 'utf8')).toBe('seeded');
928
+ // This start moved nothing, so it says nothing was moved.
929
+ expect(discarded).toEqual([]);
930
+ expect(await fs.readdir(path.join(root, 'set-aside'))).toEqual([]);
931
+ });
932
+
933
+ it('tells the listener a second time when the first telling fails', async () => {
934
+ await populatedUpstream();
935
+ await halfMadeClone();
936
+ let told = 0;
937
+ const onCloneDiscarded = async () => {
938
+ if (++told === 1) throw new Error('the queue could not be reached');
939
+ };
940
+
941
+ await makeRunner([touchDefault], { onCloneDiscarded }).runAll();
942
+
943
+ expect(told).toBe(2);
944
+ expect(await fs.readFile(path.join(local(), 'marker.txt'), 'utf8')).toBe('seeded');
945
+ });
946
+
947
+ it('does not remove a clone somebody started at the path once it was announced empty', async () => {
948
+ await populatedUpstream();
949
+ await halfMadeClone();
950
+ // Told the copy is gone, the rest of the process may clone there at once.
951
+ const onCloneDiscarded = async () => {
952
+ await git(root, ['clone', '-b', DEFAULT_BRANCH, upstream, local()]);
953
+ await fs.writeFile(path.join(local(), 'theirs.txt'), 'a branch being opened wrote this', 'utf8');
954
+ };
955
+
956
+ await makeRunner([touchDefault], { onCloneDiscarded }).runAll();
957
+
958
+ expect(await fs.readFile(path.join(local(), 'theirs.txt'), 'utf8')).toBe('a branch being opened wrote this');
959
+ expect(await fs.readdir(path.join(root, 'set-aside'))).toHaveLength(1);
960
+ });
961
+
962
+ it('leaves a clone that is still being written, and uses it once it is finished', async () => {
963
+ await populatedUpstream();
964
+ await halfMadeClone();
965
+ // Somebody is still cloning: it finishes while this start waits for quiet.
966
+ const finishing = (async () => {
967
+ await new Promise((resolve) => setTimeout(resolve, 300));
968
+ await git(local(), ['fetch', 'origin']);
969
+ await git(local(), ['checkout', DEFAULT_BRANCH]);
970
+ })();
971
+
972
+ await makeRunner([touchDefault], { unfinishedCloneQuietMs: 5_000 }).runAll();
973
+ await finishing;
974
+
975
+ // Theirs, where it was, with what was in it: nothing was set aside.
976
+ expect(await fs.readFile(path.join(local(), 'left-here.txt'), 'utf8')).toBe('somebody put this here');
977
+ expect(await fs.readFile(path.join(local(), 'marker.txt'), 'utf8')).toBe('seeded');
978
+ expect(await fs.access(path.join(root, 'set-aside')).then(() => true, () => false)).toBe(false);
979
+ });
980
+
981
+ it('sets an unfinished clone aside only once nothing has written to it for the quiet time', async () => {
982
+ await populatedUpstream();
983
+ await halfMadeClone();
984
+ const started = Date.now();
985
+
986
+ await makeRunner([touchDefault], { unfinishedCloneQuietMs: 1_500 }).runAll();
987
+
988
+ expect(Date.now() - started).toBeGreaterThanOrEqual(1_350);
989
+ expect(await fs.readFile(path.join(local(), 'marker.txt'), 'utf8')).toBe('seeded');
990
+ expect(await fs.readdir(path.join(root, 'set-aside'))).toHaveLength(1);
991
+ });
992
+
993
+ it('names the branch when a working copy cannot be prepared', async () => {
994
+ await populatedUpstream();
995
+ await makeRunner([touchDefault]).runAll();
996
+ // The clone is sound and has a commit, but git cannot read the branch it
997
+ // is to follow: the ref the fetch updates is damaged. Not a state this
998
+ // phase repairs, so the start stops, and says which working copy.
999
+ const refs = path.join(local(), '.git', 'refs', 'remotes', 'origin');
1000
+ await fs.rm(path.join(local(), '.git', 'packed-refs'), { force: true });
1001
+ await fs.mkdir(refs, { recursive: true });
1002
+ await fs.writeFile(path.join(refs, DEFAULT_BRANCH), 'not a commit id\n', 'utf8');
1003
+
1004
+ const err = await makeRunner([touchDefault])
1005
+ .runAll()
1006
+ .then(
1007
+ () => null,
1008
+ (e: unknown) => e as Error,
1009
+ );
1010
+ expect(err).toBeInstanceOf(ClassifiedFailure);
1011
+ expect(err?.message).toContain(`the working copy of branch "${DEFAULT_BRANCH}" could not be prepared`);
1012
+ // Nothing was set aside for it: it has a commit, so it is somebody's work.
1013
+ expect(await fs.access(path.join(root, 'set-aside')).then(() => true, () => false)).toBe(false);
1014
+ });
1015
+ });
1016
+
787
1017
  describe('KbStartupRunner — what a failed phase throws', () => {
788
1018
  it('a scrubbed message (tokens, a presigned query) that still carries what git said', async () => {
789
1019
  const prev = process.env.GITHUB_TOKEN;