@bevel-software/platform-core-backend 0.28.1 → 0.30.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 (274) hide show
  1. package/agent-guide/access-control.md +15 -0
  2. package/agent-guide/skills.md +8 -0
  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.d.ts.map +1 -1
  8. package/dist/core/catalog-revision.js +4 -2
  9. package/dist/core/catalog-revision.js.map +1 -1
  10. package/dist/core/compose-embed.d.ts +35 -0
  11. package/dist/core/compose-embed.d.ts.map +1 -0
  12. package/dist/core/compose-embed.js +14 -0
  13. package/dist/core/compose-embed.js.map +1 -0
  14. package/dist/core/core-ports.d.ts +10 -0
  15. package/dist/core/core-ports.d.ts.map +1 -1
  16. package/dist/core/core-ports.js.map +1 -1
  17. package/dist/core/create-core-server.d.ts.map +1 -1
  18. package/dist/core/create-core-server.js +8 -2
  19. package/dist/core/create-core-server.js.map +1 -1
  20. package/dist/core/create-core-services.d.ts +4 -1
  21. package/dist/core/create-core-services.d.ts.map +1 -1
  22. package/dist/core/create-core-services.js +57 -18
  23. package/dist/core/create-core-services.js.map +1 -1
  24. package/dist/core/lifecycle.d.ts +19 -6
  25. package/dist/core/lifecycle.d.ts.map +1 -1
  26. package/dist/core/lifecycle.js +17 -6
  27. package/dist/core/lifecycle.js.map +1 -1
  28. package/dist/modules/access/access-control.interface.d.ts +20 -0
  29. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  30. package/dist/modules/access/access-control.service.d.ts +68 -15
  31. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  32. package/dist/modules/access/access-control.service.js +282 -58
  33. package/dist/modules/access/access-control.service.js.map +1 -1
  34. package/dist/modules/access/access.routes.d.ts.map +1 -1
  35. package/dist/modules/access/access.routes.js +20 -6
  36. package/dist/modules/access/access.routes.js.map +1 -1
  37. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  38. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  39. package/dist/modules/auth/index.d.ts +15 -0
  40. package/dist/modules/auth/index.d.ts.map +1 -0
  41. package/dist/modules/auth/index.js +20 -0
  42. package/dist/modules/auth/index.js.map +1 -0
  43. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  44. package/dist/modules/diff/diff.service.js +7 -1
  45. package/dist/modules/diff/diff.service.js.map +1 -1
  46. package/dist/modules/embed/embed.interface.d.ts +22 -0
  47. package/dist/modules/embed/embed.interface.d.ts.map +1 -1
  48. package/dist/modules/embed/embed.service.d.ts +28 -14
  49. package/dist/modules/embed/embed.service.d.ts.map +1 -1
  50. package/dist/modules/embed/embed.service.js +38 -17
  51. package/dist/modules/embed/embed.service.js.map +1 -1
  52. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  53. package/dist/modules/embed/embed.tools.js +10 -2
  54. package/dist/modules/embed/embed.tools.js.map +1 -1
  55. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  56. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  57. package/dist/modules/kb-fs/git-dir.js +58 -0
  58. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  59. package/dist/modules/kb-fs/index.d.ts +9 -0
  60. package/dist/modules/kb-fs/index.d.ts.map +1 -0
  61. package/dist/modules/kb-fs/index.js +3 -0
  62. package/dist/modules/kb-fs/index.js.map +1 -0
  63. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  64. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  65. package/dist/modules/kb-fs/ref-files.js +249 -0
  66. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  67. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
  68. package/dist/modules/plugins/join-request-jobs.service.js +43 -8
  69. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
  70. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  71. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  72. package/dist/modules/plugins/teams.routes.js +9 -1
  73. package/dist/modules/plugins/teams.routes.js.map +1 -1
  74. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  75. package/dist/modules/skills/skills.tools.d.ts +8 -1
  76. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  77. package/dist/modules/skills/skills.tools.js +115 -11
  78. package/dist/modules/skills/skills.tools.js.map +1 -1
  79. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  80. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  81. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  82. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  83. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  84. package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
  85. package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
  86. package/dist/modules/tool-helpers/default-branch-write.js +63 -0
  87. package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
  88. package/dist/modules/tool-helpers/index.d.ts +1 -0
  89. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  90. package/dist/modules/tool-helpers/index.js +1 -0
  91. package/dist/modules/tool-helpers/index.js.map +1 -1
  92. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  93. package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
  94. package/dist/modules/workflow/file-lock.service.d.ts +27 -5
  95. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  96. package/dist/modules/workflow/file-lock.service.js +70 -18
  97. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  98. package/dist/modules/workflow/git/git.service.d.ts +42 -0
  99. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  100. package/dist/modules/workflow/git/git.service.js +201 -24
  101. package/dist/modules/workflow/git/git.service.js.map +1 -1
  102. package/dist/modules/workflow/index.d.ts +8 -0
  103. package/dist/modules/workflow/index.d.ts.map +1 -0
  104. package/dist/modules/workflow/index.js +8 -0
  105. package/dist/modules/workflow/index.js.map +1 -0
  106. package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
  107. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  108. package/dist/modules/workflow/pending-commits.service.js +4 -24
  109. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  110. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  111. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  112. package/dist/modules/workflow/workflow.routes.js +20 -1
  113. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  114. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/workflow.service.js +5 -3
  116. package/dist/modules/workflow/workflow.service.js.map +1 -1
  117. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  118. package/dist/modules/workspace/agent-access.gate.js +2 -2
  119. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  120. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  121. package/dist/modules/workspace/bounded-matcher.js +261 -0
  122. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  123. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  124. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  125. package/dist/modules/workspace/fetch-instant.js +13 -0
  126. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  127. package/dist/modules/workspace/index.d.ts +17 -0
  128. package/dist/modules/workspace/index.d.ts.map +1 -0
  129. package/dist/modules/workspace/index.js +16 -0
  130. package/dist/modules/workspace/index.js.map +1 -0
  131. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  132. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  133. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  134. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  135. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  136. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  137. package/dist/modules/workspace/kb-repository.js +811 -0
  138. package/dist/modules/workspace/kb-repository.js.map +1 -0
  139. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  140. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  141. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  142. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  143. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  144. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  145. package/dist/modules/workspace/routine-write-policy.js +0 -5
  146. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  147. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  148. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  149. package/dist/modules/workspace/set-aside-clone.js +139 -0
  150. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  151. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  152. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  153. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  154. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  155. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  156. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  157. package/dist/modules/workspace/workspace.service.js +240 -161
  158. package/dist/modules/workspace/workspace.service.js.map +1 -1
  159. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  160. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  161. package/dist/modules/workspace/workspace.tools.js +268 -356
  162. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  163. package/dist/shared/workspace-id.d.ts +17 -0
  164. package/dist/shared/workspace-id.d.ts.map +1 -1
  165. package/dist/shared/workspace-id.js +24 -0
  166. package/dist/shared/workspace-id.js.map +1 -1
  167. package/dist/test-setup.js +4 -0
  168. package/dist/test-setup.js.map +1 -1
  169. package/mcp-app/page.html +142 -8
  170. package/package.json +3 -3
  171. package/src/__tests__/test-repositories.ts +22 -0
  172. package/src/__tests__/workspace-service.ts +69 -0
  173. package/src/core/__tests__/compose-embed.test.ts +65 -0
  174. package/src/core/__tests__/lifecycle.test.ts +45 -26
  175. package/src/core/catalog-revision.ts +4 -2
  176. package/src/core/compose-embed.ts +50 -0
  177. package/src/core/core-ports.ts +10 -0
  178. package/src/core/create-core-server.ts +8 -2
  179. package/src/core/create-core-services.ts +63 -24
  180. package/src/core/lifecycle.ts +34 -12
  181. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +443 -25
  182. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  183. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  184. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  185. package/src/modules/access/access-control.interface.ts +22 -0
  186. package/src/modules/access/access-control.service.ts +302 -58
  187. package/src/modules/access/access.routes.ts +22 -6
  188. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  189. package/src/modules/auth/index.ts +29 -0
  190. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  191. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  192. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  193. package/src/modules/diff/diff.service.ts +6 -4
  194. package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
  195. package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
  196. package/src/modules/embed/embed.interface.ts +24 -0
  197. package/src/modules/embed/embed.service.ts +44 -19
  198. package/src/modules/embed/embed.tools.ts +10 -2
  199. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  200. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  201. package/src/modules/kb-fs/git-dir.ts +53 -0
  202. package/src/modules/kb-fs/index.ts +8 -0
  203. package/src/modules/kb-fs/ref-files.ts +279 -0
  204. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
  205. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  206. package/src/modules/plugins/join-request-jobs.service.ts +45 -8
  207. package/src/modules/plugins/teams.routes.ts +14 -1
  208. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  209. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  210. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
  211. package/src/modules/skills/skills.contract.ts +4 -0
  212. package/src/modules/skills/skills.tools.ts +120 -10
  213. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  214. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  215. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  216. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
  217. package/src/modules/tool-helpers/default-branch-write.ts +90 -0
  218. package/src/modules/tool-helpers/index.ts +5 -0
  219. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  220. package/src/modules/tool-helpers/tool.contract.ts +2 -2
  221. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +42 -3
  222. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
  223. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
  224. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
  225. package/src/modules/workflow/file-lock.service.ts +79 -18
  226. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  227. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
  228. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  229. package/src/modules/workflow/git/git.service.ts +207 -22
  230. package/src/modules/workflow/index.ts +7 -0
  231. package/src/modules/workflow/pending-commits.service.ts +4 -23
  232. package/src/modules/workflow/workflow-hooks.ts +1 -1
  233. package/src/modules/workflow/workflow.routes.ts +24 -1
  234. package/src/modules/workflow/workflow.service.ts +3 -2
  235. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  236. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  237. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  238. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  239. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  240. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  241. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  242. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  243. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  244. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  245. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  246. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  247. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  248. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  249. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  250. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  251. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  252. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  253. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  254. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  255. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  256. package/src/modules/workspace/__tests__/workspace.tools.test.ts +174 -309
  257. package/src/modules/workspace/agent-access.gate.ts +2 -2
  258. package/src/modules/workspace/bounded-matcher.ts +328 -0
  259. package/src/modules/workspace/fetch-instant.ts +13 -0
  260. package/src/modules/workspace/index.ts +26 -0
  261. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  262. package/src/modules/workspace/kb-repository.ts +844 -0
  263. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  264. package/src/modules/workspace/routine-write-policy.ts +0 -15
  265. package/src/modules/workspace/set-aside-clone.ts +140 -0
  266. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  267. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  268. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  269. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  270. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  271. package/src/modules/workspace/workspace.service.ts +247 -169
  272. package/src/modules/workspace/workspace.tools.ts +299 -385
  273. package/src/shared/workspace-id.ts +24 -0
  274. package/src/test-setup.ts +5 -0
package/mcp-app/page.html CHANGED
@@ -72,6 +72,26 @@
72
72
  * owns the view; later ones are ignored.
73
73
  */
74
74
  var loading = false;
75
+ /**
76
+ * What the view calls `open_page` with to renew its token: the path
77
+ * and heading the result that owns the view echoed. Null until then,
78
+ * and when the result echoed no path (nothing to ask for).
79
+ */
80
+ var pageArgs = null;
81
+ /** The deployment the owning result named; a renewed token must name the same one. */
82
+ var deployment = null;
83
+ /**
84
+ * Whether the host runs a server tool for the view (`tools/call`,
85
+ * app to host): true or false when the handshake's reply said so in
86
+ * `hostCapabilities.serverTools`, null when it said nothing — then
87
+ * the view tries once, and a refusal is the answer.
88
+ */
89
+ var serverTools = null;
90
+ /** Replies the view is waiting for, by request id. */
91
+ var pending = {};
92
+ var callCount = 0;
93
+ /** The one renewal in flight, shared by every call refused meanwhile. */
94
+ var renewing = null;
75
95
 
76
96
  /** The name the deployment's Vite build gives the embed entry. */
77
97
  var EMBED_ENTRY = 'embed';
@@ -90,6 +110,14 @@
90
110
  * part of the conversation rather than the whole screen.
91
111
  */
92
112
  var PREFERRED_HEIGHT = 640;
113
+ /** The tool that minted the token, and the one that renews it. */
114
+ var OPEN_PAGE_TOOL = 'open_page';
115
+ /**
116
+ * How long a renewal waits for the host before it gives up and the
117
+ * view says it has expired. A host that never answers must not leave
118
+ * a save spinning.
119
+ */
120
+ var RENEW_TIMEOUT_MS = 20000;
93
121
 
94
122
  function send(message) {
95
123
  // The host is this view's parent; it told us its origin on the
@@ -128,6 +156,87 @@
128
156
  });
129
157
  }
130
158
 
159
+ /**
160
+ * Ask the host to call one of this server's tools for the view, over
161
+ * the connection the chat already holds — and so as the identity
162
+ * that connection authenticated. Resolves with the CallToolResult;
163
+ * rejects when the host refuses or does not answer in time.
164
+ */
165
+ function callServerTool(name, args) {
166
+ return new Promise(function (resolve, reject) {
167
+ callCount += 1;
168
+ var id = 'call-' + callCount;
169
+ var timer = setTimeout(function () {
170
+ delete pending[id];
171
+ var silent = new Error('the chat app did not answer');
172
+ silent.timedOut = true;
173
+ reject(silent);
174
+ }, RENEW_TIMEOUT_MS);
175
+ pending[id] = function (message) {
176
+ clearTimeout(timer);
177
+ delete pending[id];
178
+ if (message.error || !message.result) reject(message.error || new Error('no result'));
179
+ else resolve(message.result);
180
+ };
181
+ send({ jsonrpc: '2.0', id: id, method: 'tools/call', params: { name: name, arguments: args } });
182
+ });
183
+ }
184
+
185
+ /**
186
+ * A fresh token for the page this view shows, or null when there is
187
+ * none to be had. The view cannot mint one — minting needs the
188
+ * identity, which lives on the chat's connection — so it asks the
189
+ * host to call `open_page` again for the same path and heading and
190
+ * takes the token out of the `embedUrl` it answers. That call goes
191
+ * through `open_page`'s own gate: a reader whose access was
192
+ * withdrawn is refused there exactly as on a first call, and gets
193
+ * null here.
194
+ *
195
+ * Null without asking when the host said it runs no server tools for
196
+ * a view, or after a host that said nothing refused once. The bundle
197
+ * calls this at most once per refused call (see `embed-config.ts`).
198
+ */
199
+ function renew() {
200
+ if (renewing) return renewing;
201
+ if (serverTools === false || !pageArgs || !deployment) return Promise.resolve(null);
202
+ // Every Hexis tool takes its arguments under `body`; flat ones are
203
+ // refused ("takes its arguments under body") and no token comes back.
204
+ renewing = callServerTool(OPEN_PAGE_TOOL, { body: pageArgs })
205
+ .then(
206
+ function (result) {
207
+ var data = (result && result.structuredContent) || {};
208
+ if (result && result.isError) return null;
209
+ var url;
210
+ try {
211
+ url = new URL(data.embedUrl);
212
+ } catch (err) {
213
+ return null;
214
+ }
215
+ // The same deployment, or nothing: the bundle already runs
216
+ // against this one, and its API is the token's.
217
+ if (baseOf(url) !== deployment) return null;
218
+ var token = url.searchParams.get('token');
219
+ if (!token) return null;
220
+ var handoff = window[HANDOFF_GLOBAL];
221
+ if (handoff && typeof handoff === 'object') handoff.token = token;
222
+ return token;
223
+ },
224
+ function (err) {
225
+ // The host refused the call: one that said nothing about
226
+ // server tools has now said no. One that did not answer in
227
+ // time has said nothing, and is asked again on the next
228
+ // refused call.
229
+ if (serverTools === null && !(err && err.timedOut)) serverTools = false;
230
+ return null;
231
+ },
232
+ )
233
+ .then(function (token) {
234
+ renewing = null;
235
+ return token;
236
+ });
237
+ return renewing;
238
+ }
239
+
131
240
  /** The text blocks of a CallToolResult, one line each. */
132
241
  function textOf(result) {
133
242
  var out = [];
@@ -226,8 +335,15 @@
226
335
  }
227
336
  if (loading) return;
228
337
  loading = true;
338
+ deployment = base;
339
+ // What the view calls `open_page` with again when the token runs
340
+ // out: the arguments this result was answered for, as it echoed them.
341
+ if (typeof data.path === 'string' && data.path) {
342
+ pageArgs = { path: data.path };
343
+ if (typeof data.heading === 'string' && data.heading) pageArgs.heading = data.heading;
344
+ }
229
345
  // The handoff, before the bundle: it reads this the moment it runs.
230
- window[HANDOFF_GLOBAL] = { baseUrl: base, token: token, openLink: openLink };
346
+ window[HANDOFF_GLOBAL] = { baseUrl: base, token: token, openLink: openLink, renew: renew };
231
347
 
232
348
  fetch(base + MANIFEST_PATH, { credentials: 'omit' })
233
349
  .then(function (res) {
@@ -302,6 +418,13 @@
302
418
  if (event.source !== window.parent) return;
303
419
  if (hostOrigin === null && event.origin) hostOrigin = event.origin;
304
420
 
421
+ // A reply to a request the view sent (a server tool call): it has
422
+ // the request's id and no method of its own.
423
+ if (!message.method && typeof message.id === 'string' && pending[message.id]) {
424
+ pending[message.id](message);
425
+ return;
426
+ }
427
+
305
428
  if (message.method === 'ui/notifications/tool-result') {
306
429
  // The params ARE the CallToolResult. A `result` wrapper is
307
430
  // accepted too, for a host that wrapped it.
@@ -314,16 +437,25 @@
314
437
  // whole of the layout; nothing to recompute.
315
438
  return;
316
439
  }
317
- // The handshake's reply carries the host's capabilities. Nothing
318
- // here depends on them, but a host that sends the first tool result
319
- // only after the handshake completes needs us to have sent it.
320
- if (message.id === INITIALIZE_ID) {
440
+ // The handshake's reply carries the host's capabilities. The one
441
+ // read here is `serverTools`: whether the host runs this server's
442
+ // tools for the view, which is how the view renews its token. And a
443
+ // host that sends the first tool result only after the handshake
444
+ // completes needs us to have sent it.
445
+ if (!message.method && message.id === INITIALIZE_ID) {
321
446
  // A rejected handshake is not advertised as ready — and the
322
447
  // reader is told, rather than left at "Opening the page…".
323
448
  if (message.error || !message.result) {
324
449
  if (!shown) say('The chat app refused to open this page.');
325
450
  return;
326
451
  }
452
+ // Only a `serverTools` the host named decides: present is yes,
453
+ // false or null is no. Capabilities that leave it out have said
454
+ // nothing about it, and the view tries once.
455
+ var caps = message.result.hostCapabilities;
456
+ if (caps && typeof caps === 'object' && caps.serverTools !== undefined) {
457
+ serverTools = !!caps.serverTools;
458
+ }
327
459
  send({ jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} });
328
460
  if (!shown) say('Opening the page…');
329
461
  }
@@ -337,9 +469,11 @@
337
469
  params: {
338
470
  protocolVersion: '2026-01-26',
339
471
  appInfo: { name: 'hexis-knowledge-page', version: '1.0.0' },
340
- // Asks the host for nothing: no tool calls, no messages, no
341
- // model context. Links go out through ui/open-link, which every
342
- // host offers.
472
+ // Declares nothing. The extension has no way for a view to say
473
+ // it CALLS server tools (`tools` here would mean the view
474
+ // exposes tools of its own); whether the host runs them for a
475
+ // view is the host's to say, in the reply's `serverTools`.
476
+ // Links go out through ui/open-link, which every host offers.
343
477
  appCapabilities: {},
344
478
  },
345
479
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bevel-software/platform-core-backend",
3
- "version": "0.28.1",
3
+ "version": "0.30.0",
4
4
  "description": "Open-source core backend of the Bevel platform: git-backed knowledge workspace, workflow (branches/change requests/locks/SSE), skills, tools, secrets vault, access control and the remote MCP surface.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,8 +55,8 @@
55
55
  "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
56
56
  "yaml": "^2.9.0",
57
57
  "zod": "^3.24.0",
58
- "@bevel-software/platform-mcp-core": "0.28.1",
59
- "@bevel-software/platform-shared": "0.28.1"
58
+ "@bevel-software/platform-mcp-core": "0.30.0",
59
+ "@bevel-software/platform-shared": "0.30.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/adm-zip": "^0.5.8",
@@ -0,0 +1,22 @@
1
+ import fs from 'node:fs/promises';
2
+
3
+ /**
4
+ * The repository roots `testKbRepository` placed beside a suite's workspaces
5
+ * root (`<workspacesRoot>.repository`). A suite removes its fixture root, not
6
+ * what sits next to it, so `test-setup.ts` removes these once the file's
7
+ * suites are done. Kept free of imports so the setup file can load it without
8
+ * pulling in a module a suite means to mock.
9
+ */
10
+ const roots = new Set<string>();
11
+
12
+ export function recordTestRepositoryRoot(root: string): void {
13
+ roots.add(root);
14
+ }
15
+
16
+ export async function removeTestRepositoryRoots(): Promise<void> {
17
+ const pending = [...roots];
18
+ roots.clear();
19
+ await Promise.all(
20
+ pending.map((root) => fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })),
21
+ );
22
+ }
@@ -0,0 +1,69 @@
1
+ import path from 'node:path';
2
+ import { NodeGitRunner } from '../modules/workflow/git/node-git-runner.js';
3
+ import type { IGitRunner } from '../shared/git.contract.js';
4
+ import type { ITreeWalker } from '../shared/fs.contract.js';
5
+ import type { KbContext } from '../shared/kb-context.js';
6
+ import { KbRepository, repositoryDirFor } from '../modules/workspace/kb-repository.js';
7
+ import type { IKbRepository } from '../modules/workspace/kb-repository.contract.js';
8
+ import { WorkspaceService } from '../modules/workspace/workspace.service.js';
9
+ import { KbStartupRunner, type KbStartupRunnerOptions } from '../modules/workspace/startup/kb-startup-runner.js';
10
+ import { recordTestRepositoryRoot } from './test-repositories.js';
11
+
12
+ /**
13
+ * The repository a suite's services share when it builds them by hand: one
14
+ * next to the workspaces root (see `repositoryDirFor`), as the composition
15
+ * root would place it there for a deployment with no backups root. Removed
16
+ * once the file's suites are done (see `test-repositories.ts`): a suite's
17
+ * teardown removes its fixture root, not what sits beside it.
18
+ */
19
+ export function testKbRepository(
20
+ workspacesRoot: string,
21
+ kbDirName: string,
22
+ kbRepoUrl: string | (() => string),
23
+ gitRunner?: IGitRunner,
24
+ ): IKbRepository {
25
+ const dir = repositoryDirFor(workspacesRoot, kbDirName);
26
+ recordTestRepositoryRoot(path.dirname(dir));
27
+ return new KbRepository({
28
+ dir,
29
+ kbRepoUrl: typeof kbRepoUrl === 'function' ? kbRepoUrl : () => kbRepoUrl,
30
+ gitRunner: gitRunner ?? new NodeGitRunner(),
31
+ });
32
+ }
33
+
34
+ /**
35
+ * A WorkspaceService as a suite builds it: the constructor's arguments, with
36
+ * the repository — which only the composition root builds in the product —
37
+ * made here when the suite passes none.
38
+ */
39
+ export function newWorkspaceService(
40
+ workspacesRoot: string,
41
+ kbRepoUrl: string | (() => string),
42
+ kb: Pick<KbContext, 'kbDirName' | 'defaultBranch'>,
43
+ disk: ITreeWalker,
44
+ gitRunner: IGitRunner = new NodeGitRunner(),
45
+ setAsideRoot?: string,
46
+ aroundSetAside?: (workspaceId: string, move: () => Promise<void>) => Promise<void>,
47
+ repository?: IKbRepository,
48
+ ): WorkspaceService {
49
+ return new WorkspaceService(
50
+ workspacesRoot,
51
+ kbRepoUrl,
52
+ kb,
53
+ disk,
54
+ gitRunner,
55
+ setAsideRoot,
56
+ aroundSetAside,
57
+ repository ?? testKbRepository(workspacesRoot, kb.kbDirName, kbRepoUrl, gitRunner),
58
+ );
59
+ }
60
+
61
+ /** A KbStartupRunner as a suite builds it — the repository made here when the suite passes none. */
62
+ export function newKbStartupRunner(
63
+ opts: Omit<KbStartupRunnerOptions, 'repository'> & { repository?: IKbRepository },
64
+ ): KbStartupRunner {
65
+ return new KbStartupRunner({
66
+ ...opts,
67
+ repository: opts.repository ?? testKbRepository(opts.workspacesRoot, opts.kbDirName, opts.kbRepoUrl, opts.gitRunner),
68
+ });
69
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { testKbContext, TEST_BRANCH_MODEL } from '../../__tests__/kb-context.js';
3
+ import { makeRolesYamlWriteValidator } from '../../modules/access-model/roles-yaml-guard.js';
4
+ import { createFileReaderRegistry } from '../../modules/workspace/file-readers/file-reader.registry.js';
5
+ import type { DocExtractOutcome, DocExtractService } from '../../modules/workspace/file-readers/doc-extract.service.js';
6
+ import { composeEmbedService, type EmbedComposition } from '../compose-embed.js';
7
+ import { EmbedRefParseError } from '../../modules/embed/embed-link.js';
8
+
9
+ const KB = 'knowledge-base';
10
+ const BRANCH = TEST_BRANCH_MODEL.defaultBranch;
11
+ const REPO = 'Data/Thing.md';
12
+ const USER = { id: 'u-1', email: 'alice@bevel.software', name: 'Alice' };
13
+
14
+ /** The composition root's deps, as mocks: enough to mint and to say who is looking. */
15
+ function deps(): EmbedComposition {
16
+ const extract = async (): Promise<DocExtractOutcome> => ({ ok: false, message: 'no documents here' });
17
+ return {
18
+ config: { jwtSecret: 'test-jwt-secret', embedSharedSecret: '', publicFrontendUrl: 'https://hexis.example', kbDirName: KB },
19
+ kb: testKbContext({ kbDirName: KB }),
20
+ workspaceService: {
21
+ getOrCreateForBranch: vi.fn(async (branch: string) => ({ id: encodeURIComponent(branch), kbDirName: KB })),
22
+ isFile: vi.fn(async (_id: string, wsPath: string) => wsPath === `${KB}/${REPO}`),
23
+ readFileBinary: vi.fn(async () => Buffer.from('# Thing\n')),
24
+ writeFile: vi.fn(async () => undefined),
25
+ withPathTurn: vi.fn(async (_id: string, _p: string, op: () => Promise<unknown>) => op()),
26
+ },
27
+ accessControl: { canRead: vi.fn(async () => true), canWrite: vi.fn(async () => true) } as never,
28
+ authService: { getUserById: vi.fn(async () => USER), isEmailDomainAllowed: () => true, isActive: async () => true },
29
+ workflowService: {} as never,
30
+ gitService: {} as never,
31
+ accountLinks: { getUserId: async () => null, link: async () => undefined, listForUser: async () => [], unlink: async () => true },
32
+ readers: createFileReaderRegistry({ extract } satisfies Pick<DocExtractService, 'extract'> as DocExtractService),
33
+ validateWrite: makeRolesYamlWriteValidator(KB),
34
+ };
35
+ }
36
+
37
+ /**
38
+ * The composition root is the one place the distribution's ports reach the
39
+ * embed. Pinned here through that composition, not by handing the service a
40
+ * resolver directly: a node-id reference resolves through the port when one
41
+ * is registered, and is refused when none is — which is what a core-only
42
+ * deployment ships.
43
+ */
44
+ describe('composeEmbedService', () => {
45
+ // The app's copy-link form: the full address of a node, which is its id.
46
+ const NODE_LINK = `https://hexis.example/workspace/${encodeURIComponent(BRANCH)}/abc-123`;
47
+
48
+ it('resolves a node-id reference through the registered port, and mints for the file it names', async () => {
49
+ const embedNodeIdResolver = vi.fn(async (id: string) => (id === 'abc-123' ? REPO : null));
50
+ const service = composeEmbedService(deps(), { embedNodeIdResolver });
51
+ const { token } = await service.mintForUser({ userId: USER.id, reference: NODE_LINK });
52
+ expect(embedNodeIdResolver).toHaveBeenCalledWith('abc-123');
53
+ expect((await service.viewerOf(token)).repoRelative).toBe(REPO);
54
+ // An id the graph does not know is refused as a dead reference.
55
+ await expect(service.mintForUser({ userId: USER.id, reference: 'nope-1' })).rejects.toThrow(EmbedRefParseError);
56
+ });
57
+
58
+ it('refuses a node-id reference when no port is registered — core has no graph', async () => {
59
+ const service = composeEmbedService(deps(), {});
60
+ await expect(service.mintForUser({ userId: USER.id, reference: NODE_LINK })).rejects.toThrow(EmbedRefParseError);
61
+ // A path reference is untouched by the absence of a resolver.
62
+ const { token } = await service.mintForUser({ userId: USER.id, reference: REPO });
63
+ expect((await service.viewerOf(token)).repoRelative).toBe(REPO);
64
+ });
65
+ });
@@ -1,7 +1,11 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { createShutdown, holdCommitWorkerLease, holdable, stopCore, withStartupTask, type LeasedWorker } from '../lifecycle.js';
2
+ import { createShutdown, stopCore, withStartupTask, type LeasedWorker } from '../../index.js';
3
+ // The lease helpers are the module's own, not on the entry point: their
4
+ // ordering is pinned here, where the module is the unit under test.
5
+ import { holdCommitWorkerLease, holdable } from '../lifecycle.js';
3
6
  import { BevelSecretsVariableLoader, registerBevelSecretsVariableLoader } from '../../modules/secrets-vault/secrets-variable-loader.js';
4
7
  import type { AdvisoryLease } from '../../modules/database/advisory-lock.js';
8
+ import { WorkerPatternMatcher } from '../../modules/workspace/bounded-matcher.js';
5
9
 
6
10
  /** A lease the suite scripts: what each `tryAcquire` answers, and a way to lose it. */
7
11
  function fakeLease(answers: boolean[]) {
@@ -254,12 +258,12 @@ describe('createShutdown', () => {
254
258
  order.push('commitWorker.stop');
255
259
  },
256
260
  },
257
- backgroundJobs: {
261
+ pluginJoinRequestJobs: {
258
262
  stopSweeping() {
259
- order.push('backgroundJobs.stopSweeping');
263
+ order.push('pluginJoinRequestJobs.stopSweeping');
260
264
  },
261
265
  async drain() {
262
- order.push('backgroundJobs.drain');
266
+ order.push('pluginJoinRequestJobs.drain');
263
267
  },
264
268
  },
265
269
  agentUploadStore: {
@@ -296,12 +300,12 @@ describe('createShutdown', () => {
296
300
  expect(d.order).toEqual([
297
301
  'server.close',
298
302
  'server.closeAllConnections',
299
- 'backgroundJobs.stopSweeping',
303
+ 'pluginJoinRequestJobs.stopSweeping',
300
304
  // The upload sweep is stopped in the same breath as the other timers,
301
305
  // and drained before the pool goes: a graph that is stopping must not
302
306
  // leave a sweep walking the staging root its replacement will stage into.
303
307
  'agentUploadStore.stopSweeping',
304
- 'backgroundJobs.drain',
308
+ 'pluginJoinRequestJobs.drain',
305
309
  'agentUploadStore.drainSweep',
306
310
  'commitWorker.stop',
307
311
  'db.end',
@@ -311,8 +315,8 @@ describe('createShutdown', () => {
311
315
  it('waits for the jobs a tick already started before the pool goes, within the budget', async () => {
312
316
  const d = deps();
313
317
  let release: () => void = () => undefined;
314
- d.deps.backgroundJobs!.drain = () => {
315
- d.order.push('backgroundJobs.drain');
318
+ d.deps.pluginJoinRequestJobs!.drain = () => {
319
+ d.order.push('pluginJoinRequestJobs.drain');
316
320
  return new Promise<void>((resolve) => {
317
321
  release = resolve;
318
322
  });
@@ -326,9 +330,9 @@ describe('createShutdown', () => {
326
330
  expect(d.order).toEqual([
327
331
  'server.close',
328
332
  'server.closeAllConnections',
329
- 'backgroundJobs.stopSweeping',
333
+ 'pluginJoinRequestJobs.stopSweeping',
330
334
  'agentUploadStore.stopSweeping',
331
- 'backgroundJobs.drain',
335
+ 'pluginJoinRequestJobs.drain',
332
336
  ]);
333
337
  release();
334
338
  await done;
@@ -348,7 +352,7 @@ describe('createShutdown', () => {
348
352
  // `commitWorker.stop` — which awaits an in-flight commit and can take
349
353
  // most of the shutdown budget — would start fresh clone/commit/push work
350
354
  // that the process exit immediately after kills mid-git.
351
- const stopped = d.order.indexOf('backgroundJobs.stopSweeping');
355
+ const stopped = d.order.indexOf('pluginJoinRequestJobs.stopSweeping');
352
356
  expect(stopped).toBeGreaterThanOrEqual(0);
353
357
  expect(stopped).toBeLessThan(d.order.indexOf('commitWorker.stop'));
354
358
  expect(stopped).toBeLessThan(d.order.indexOf('db.end'));
@@ -357,7 +361,7 @@ describe('createShutdown', () => {
357
361
  it('shuts down cleanly when no background jobs were built yet', async () => {
358
362
  const d = deps();
359
363
  // A stop that lands mid-boot: the services exist, the jobs do not.
360
- delete (d.deps as { backgroundJobs?: unknown }).backgroundJobs;
364
+ delete (d.deps as { pluginJoinRequestJobs?: unknown }).pluginJoinRequestJobs;
361
365
  delete (d.deps as { agentUploadStore?: unknown }).agentUploadStore;
362
366
  const shutdown = createShutdown(d.deps as never);
363
367
  const done = shutdown('SIGTERM');
@@ -374,8 +378,8 @@ describe('createShutdown', () => {
374
378
 
375
379
  it('does not wait past the budget for jobs that never finish, and still ends the pool', async () => {
376
380
  const d = deps();
377
- d.deps.backgroundJobs!.drain = () => {
378
- d.order.push('backgroundJobs.drain');
381
+ d.deps.pluginJoinRequestJobs!.drain = () => {
382
+ d.order.push('pluginJoinRequestJobs.drain');
379
383
  return new Promise<void>(() => undefined); // a clone that never returns
380
384
  };
381
385
  const shutdown = createShutdown(d.deps as never, { deadlineMs: 60 });
@@ -384,7 +388,7 @@ describe('createShutdown', () => {
384
388
  d.finishClose();
385
389
  await done;
386
390
  expect(d.order.slice(-4)).toEqual([
387
- 'backgroundJobs.drain',
391
+ 'pluginJoinRequestJobs.drain',
388
392
  'agentUploadStore.drainSweep',
389
393
  'commitWorker.stop',
390
394
  'db.end',
@@ -400,9 +404,9 @@ describe('createShutdown', () => {
400
404
  expect(d.order).toEqual([
401
405
  'server.close',
402
406
  'server.closeAllConnections',
403
- 'backgroundJobs.stopSweeping',
407
+ 'pluginJoinRequestJobs.stopSweeping',
404
408
  'agentUploadStore.stopSweeping',
405
- 'backgroundJobs.drain',
409
+ 'pluginJoinRequestJobs.drain',
406
410
  'agentUploadStore.drainSweep',
407
411
  'commitWorker.stop',
408
412
  'db.end',
@@ -432,12 +436,12 @@ describe('stopCore', () => {
432
436
  order.push('commitWorker.stop');
433
437
  },
434
438
  },
435
- backgroundJobs: {
439
+ pluginJoinRequestJobs: {
436
440
  stopSweeping() {
437
- order.push('backgroundJobs.stopSweeping');
441
+ order.push('pluginJoinRequestJobs.stopSweeping');
438
442
  },
439
443
  async drain() {
440
- order.push('backgroundJobs.drain');
444
+ order.push('pluginJoinRequestJobs.drain');
441
445
  },
442
446
  },
443
447
  agentUploadStore: {
@@ -478,11 +482,11 @@ describe('stopCore', () => {
478
482
  const g = graph();
479
483
  await stopCore(g.core as never);
480
484
  expect(g.order).toEqual([
481
- 'backgroundJobs.stopSweeping',
485
+ 'pluginJoinRequestJobs.stopSweeping',
482
486
  'agentUploadStore.stopSweeping',
483
487
  'startupRetry.stop',
484
488
  'workflowService.stopTidying',
485
- 'backgroundJobs.drain',
489
+ 'pluginJoinRequestJobs.drain',
486
490
  'agentUploadStore.drainSweep',
487
491
  'workflowService.drainTidy',
488
492
  'commitWorker.stop',
@@ -523,10 +527,10 @@ describe('stopCore', () => {
523
527
  const core = { ...g.core, startupRetry: null, secretsScope: undefined };
524
528
  await stopCore(core as never);
525
529
  expect(g.order).toEqual([
526
- 'backgroundJobs.stopSweeping',
530
+ 'pluginJoinRequestJobs.stopSweeping',
527
531
  'agentUploadStore.stopSweeping',
528
532
  'workflowService.stopTidying',
529
- 'backgroundJobs.drain',
533
+ 'pluginJoinRequestJobs.drain',
530
534
  'agentUploadStore.drainSweep',
531
535
  'workflowService.drainTidy',
532
536
  'commitWorker.stop',
@@ -534,16 +538,31 @@ describe('stopCore', () => {
534
538
  ]);
535
539
  });
536
540
 
541
+ it('ends the grep matcher\'s workers, so an evicted graph leaves no search running behind it', async () => {
542
+ const g = graph();
543
+ const patternMatcher = new WorkerPatternMatcher();
544
+ // A search holding a worker for its whole 5 s budget when the graph is stopped.
545
+ const running = patternMatcher.search('^(a+)+$', '').matchingLines([`${'a'.repeat(39)}!`], 1);
546
+ await new Promise((r) => setTimeout(r, 100));
547
+ const started = Date.now();
548
+ await stopCore({ ...g.core, patternMatcher } as never);
549
+ await expect(running).rejects.toMatchObject({ status: 503, details: { code: 'search_busy' } });
550
+ expect(Date.now() - started).toBeLessThan(1_000);
551
+ // Closed for good: nothing after the stop starts a worker.
552
+ await expect(patternMatcher.search('b', '').matchingLines(['b'], 1)).rejects.toMatchObject({ status: 503 });
553
+ expect(g.order.at(-1)).toBe('db.end');
554
+ });
555
+
537
556
  it('does not wait past the budget for a worker that never stops, and still ends the pool', async () => {
538
557
  const g = graph();
539
558
  const core = { ...g.core, commitWorker: { stop: () => new Promise<void>(() => undefined) } };
540
559
  await stopCore(core as never, { deadlineMs: 50 });
541
560
  expect(g.order).toEqual([
542
- 'backgroundJobs.stopSweeping',
561
+ 'pluginJoinRequestJobs.stopSweeping',
543
562
  'agentUploadStore.stopSweeping',
544
563
  'startupRetry.stop',
545
564
  'workflowService.stopTidying',
546
- 'backgroundJobs.drain',
565
+ 'pluginJoinRequestJobs.drain',
547
566
  'agentUploadStore.drainSweep',
548
567
  'workflowService.drainTidy',
549
568
  'db.end',
@@ -29,10 +29,12 @@ const log = logger('catalog-revision');
29
29
  */
30
30
 
31
31
  /**
32
- * A skill's line: everything `list_skills` shows about it on an AGENT
32
+ * A skill's line: what `list_skills` shows about the skill ITSELF on an AGENT
33
33
  * surface. Not `plugins`, which only the browser route decorates onto the
34
34
  * catalog — a field no agent-facing listing carries cannot be a change one
35
- * owes a refresh for.
35
+ * owes a refresh for. Not `canWrite` either: it is a verdict about the caller,
36
+ * judged per call by the tool, not part of the skill, and it is never on the
37
+ * summaries this digest reads.
36
38
  */
37
39
  function skillFingerprint(s: SkillSummary): string {
38
40
  return [s.name, s.path, s.version ?? '', s.description].join('\u0000');
@@ -0,0 +1,50 @@
1
+ import type { IGitService, IWorkflowService } from '@bevel-software/platform-shared';
2
+ import type { KbContext } from '../shared/kb-context.js';
3
+ import type { IAccessControl } from '../modules/access/access-control.interface.js';
4
+ import type { WriteValidator } from '../modules/kb-fs/index.js';
5
+ import type { FileReaderRegistry } from '../modules/workspace/index.js';
6
+ import type { CorePorts } from './core-ports.js';
7
+ import type { IAccountLinkService } from '../modules/embed/account-link.service.js';
8
+ import type { EmbedAuthPort, EmbedWorkspacePort } from '../modules/embed/embed.interface.js';
9
+ import { EmbedService, type EmbedConfig } from '../modules/embed/embed.service.js';
10
+
11
+ /** What the composition root hands the embed: the services it reads and writes through. */
12
+ export interface EmbedComposition {
13
+ config: EmbedConfig;
14
+ kb: KbContext;
15
+ workspaceService: EmbedWorkspacePort;
16
+ accessControl: IAccessControl;
17
+ authService: EmbedAuthPort;
18
+ workflowService: IWorkflowService;
19
+ gitService: IGitService;
20
+ accountLinks: IAccountLinkService;
21
+ /** The SAME extension-to-reader registry `read_file` dispatches on. */
22
+ readers: FileReaderRegistry;
23
+ /** The SAME pre-disk gate the file editor and the agent tools run. */
24
+ validateWrite: WriteValidator;
25
+ }
26
+
27
+ /**
28
+ * The embed service as the composition root builds it — the one place the
29
+ * distribution's ports reach it. Lives in `core/`, beside the composition
30
+ * root it serves: the ports are core's contract with a distribution, and a
31
+ * module never reaches up into core. A node-id reference (`/workspace/<branch>/
32
+ * <id>`, the app's copy-link form) resolves only where a node graph is: the
33
+ * distribution's, when it registers `ports.embedNodeIdResolver`; core has
34
+ * none and refuses such a reference.
35
+ */
36
+ export function composeEmbedService(deps: EmbedComposition, ports: Pick<CorePorts, 'embedNodeIdResolver'>): EmbedService {
37
+ return new EmbedService(
38
+ deps.config,
39
+ deps.kb,
40
+ deps.workspaceService,
41
+ deps.accessControl,
42
+ deps.authService,
43
+ deps.workflowService,
44
+ deps.gitService,
45
+ deps.accountLinks,
46
+ deps.readers,
47
+ deps.validateWrite,
48
+ ports.embedNodeIdResolver ?? null,
49
+ );
50
+ }
@@ -10,6 +10,7 @@ import type { IAccountAdmission } from '../modules/auth/account-admission.js';
10
10
  import type { IWriteAccess } from '../modules/write-access/write-access.js';
11
11
  import type { OnServerStart } from '../modules/workspace/startup/on-server-start.js';
12
12
  import type { AgentGuideHook } from '../modules/agent-guide/agent-guide.js';
13
+ import type { EmbedNodeIdResolver } from '../modules/embed/embed.interface.js';
13
14
 
14
15
  /**
15
16
  * Every seam the enterprise overlay can fill in the CORE composition
@@ -138,6 +139,15 @@ export interface CorePorts {
138
139
  * `modules/write-access/write-access.ts`.
139
140
  */
140
141
  writeAccess?: IWriteAccess;
142
+ /**
143
+ * How an embed reference that is a bare NODE ID (`/workspace/<branch>/<id>`,
144
+ * the app's copy-link form) becomes the repository path it names. Core has
145
+ * no node graph, so core refuses such a reference; a distribution with one
146
+ * answers from it, and the Atlassian connector's links to nodes mint as
147
+ * they did when that distribution served the embed itself. Returns null
148
+ * for an id that names nothing. See `modules/embed/embed.interface.ts`.
149
+ */
150
+ embedNodeIdResolver?: EmbedNodeIdResolver;
141
151
  }
142
152
 
143
153
  /**