@bevel-software/platform-core-backend 0.21.0 → 0.22.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 (177) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +32 -2
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +13 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +51 -5
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/modules/access/access-requests.contract.d.ts +75 -0
  9. package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
  10. package/dist/modules/access/access-requests.contract.js +20 -0
  11. package/dist/modules/access/access-requests.contract.js.map +1 -0
  12. package/dist/modules/access/access-requests.routes.d.ts +35 -0
  13. package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
  14. package/dist/modules/access/access-requests.routes.js +237 -0
  15. package/dist/modules/access/access-requests.routes.js.map +1 -0
  16. package/dist/modules/access/access-requests.service.d.ts +123 -0
  17. package/dist/modules/access/access-requests.service.d.ts.map +1 -0
  18. package/dist/modules/access/access-requests.service.js +337 -0
  19. package/dist/modules/access/access-requests.service.js.map +1 -0
  20. package/dist/modules/access-model/access-grammar.d.ts +12 -0
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +24 -0
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/auth/auth.service.d.ts +26 -1
  25. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  26. package/dist/modules/auth/auth.service.js +11 -2
  27. package/dist/modules/auth/auth.service.js.map +1 -1
  28. package/dist/modules/github-app/github-app.client.d.ts +101 -0
  29. package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
  30. package/dist/modules/github-app/github-app.client.js +222 -0
  31. package/dist/modules/github-app/github-app.client.js.map +1 -0
  32. package/dist/modules/github-app/github-app.connection.d.ts +102 -0
  33. package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
  34. package/dist/modules/github-app/github-app.connection.js +185 -0
  35. package/dist/modules/github-app/github-app.connection.js.map +1 -0
  36. package/dist/modules/github-app/github-app.routes.d.ts +59 -0
  37. package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
  38. package/dist/modules/github-app/github-app.routes.js +323 -0
  39. package/dist/modules/github-app/github-app.routes.js.map +1 -0
  40. package/dist/modules/github-app/index.d.ts +4 -0
  41. package/dist/modules/github-app/index.d.ts.map +1 -0
  42. package/dist/modules/github-app/index.js +4 -0
  43. package/dist/modules/github-app/index.js.map +1 -0
  44. package/dist/modules/kb-fs/remote-url.d.ts +22 -0
  45. package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
  46. package/dist/modules/kb-fs/remote-url.js +35 -0
  47. package/dist/modules/kb-fs/remote-url.js.map +1 -0
  48. package/dist/modules/plugins/join-proposals.d.ts +17 -5
  49. package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
  50. package/dist/modules/plugins/join-proposals.js +76 -22
  51. package/dist/modules/plugins/join-proposals.js.map +1 -1
  52. package/dist/modules/plugins/join-requests.service.d.ts +111 -18
  53. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  54. package/dist/modules/plugins/join-requests.service.js +173 -29
  55. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  56. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  57. package/dist/modules/plugins/plugins.routes.js +3 -2
  58. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  59. package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
  60. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  61. package/dist/modules/settings/deployment-settings.service.js +93 -4
  62. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  63. package/dist/modules/settings/managed-repository.d.ts +43 -0
  64. package/dist/modules/settings/managed-repository.d.ts.map +1 -0
  65. package/dist/modules/settings/managed-repository.js +60 -0
  66. package/dist/modules/settings/managed-repository.js.map +1 -0
  67. package/dist/modules/settings/repository-source.d.ts +128 -0
  68. package/dist/modules/settings/repository-source.d.ts.map +1 -0
  69. package/dist/modules/settings/repository-source.js +150 -0
  70. package/dist/modules/settings/repository-source.js.map +1 -0
  71. package/dist/modules/settings/setup.routes.d.ts +27 -4
  72. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  73. package/dist/modules/settings/setup.routes.js +187 -13
  74. package/dist/modules/settings/setup.routes.js.map +1 -1
  75. package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
  76. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
  77. package/dist/modules/skills/skill-access-requests.routes.js +24 -63
  78. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
  79. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  80. package/dist/modules/tool-helpers/tool-context.js +15 -0
  81. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  82. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  83. package/dist/modules/workflow/agent-tools/workflow.tools.js +38 -2
  84. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  85. package/dist/modules/workflow/git/git.service.d.ts +3 -1
  86. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  87. package/dist/modules/workflow/git/git.service.js +17 -4
  88. package/dist/modules/workflow/git/git.service.js.map +1 -1
  89. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
  90. package/dist/modules/workflow/git/node-git-runner.js +5 -0
  91. package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
  92. package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
  93. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  94. package/dist/modules/workflow/git/pull-request.service.js +99 -0
  95. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  96. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  97. package/dist/modules/workflow/workflow.routes.js +3 -1
  98. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  99. package/dist/modules/workflow/workflow.service.d.ts +34 -7
  100. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  101. package/dist/modules/workflow/workflow.service.js +148 -48
  102. package/dist/modules/workflow/workflow.service.js.map +1 -1
  103. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +66 -0
  104. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  105. package/dist/modules/workspace/startup/kb-startup-runner.js +148 -0
  106. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  107. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  108. package/dist/modules/workspace/workspace.service.js +17 -1
  109. package/dist/modules/workspace/workspace.service.js.map +1 -1
  110. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  111. package/dist/modules/workspace/workspace.tools.js +28 -2
  112. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  113. package/dist/shared/domain-errors.d.ts +39 -0
  114. package/dist/shared/domain-errors.d.ts.map +1 -1
  115. package/dist/shared/domain-errors.js +55 -0
  116. package/dist/shared/domain-errors.js.map +1 -1
  117. package/dist/shared/git.contract.d.ts +10 -0
  118. package/dist/shared/git.contract.d.ts.map +1 -1
  119. package/dist/shared/git.contract.js.map +1 -1
  120. package/package.json +3 -3
  121. package/src/core/create-core-server.ts +37 -1
  122. package/src/core/create-core-services.ts +65 -8
  123. package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
  124. package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
  125. package/src/modules/access/access-requests.contract.ts +93 -0
  126. package/src/modules/access/access-requests.routes.ts +286 -0
  127. package/src/modules/access/access-requests.service.ts +420 -0
  128. package/src/modules/access-model/access-grammar.ts +22 -0
  129. package/src/modules/auth/__tests__/auth.service.test.ts +38 -0
  130. package/src/modules/auth/auth.service.ts +28 -1
  131. package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
  132. package/src/modules/github-app/github-app.client.ts +268 -0
  133. package/src/modules/github-app/github-app.connection.ts +204 -0
  134. package/src/modules/github-app/github-app.routes.ts +359 -0
  135. package/src/modules/github-app/index.ts +19 -0
  136. package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
  137. package/src/modules/kb-fs/remote-url.ts +35 -0
  138. package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
  139. package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
  140. package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
  141. package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
  142. package/src/modules/plugins/join-proposals.ts +87 -19
  143. package/src/modules/plugins/join-requests.service.ts +199 -39
  144. package/src/modules/plugins/plugins.routes.ts +3 -2
  145. package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
  146. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +353 -0
  147. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +282 -0
  148. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +233 -0
  149. package/src/modules/settings/deployment-settings.service.ts +109 -3
  150. package/src/modules/settings/managed-repository.ts +68 -0
  151. package/src/modules/settings/repository-source.ts +197 -0
  152. package/src/modules/settings/setup.routes.ts +214 -10
  153. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
  154. package/src/modules/skills/skill-access-requests.routes.ts +25 -75
  155. package/src/modules/tool-helpers/tool-context.ts +15 -0
  156. package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
  157. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
  158. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
  159. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +141 -1
  160. package/src/modules/workflow/agent-tools/workflow.tools.ts +49 -3
  161. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
  162. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
  163. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
  164. package/src/modules/workflow/git/git.service.ts +25 -4
  165. package/src/modules/workflow/git/node-git-runner.ts +6 -0
  166. package/src/modules/workflow/git/pull-request.service.ts +119 -0
  167. package/src/modules/workflow/workflow.routes.ts +3 -1
  168. package/src/modules/workflow/workflow.service.ts +170 -52
  169. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
  170. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +233 -7
  171. package/src/modules/workspace/__tests__/workspace.tools.test.ts +35 -3
  172. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +211 -0
  173. package/src/modules/workspace/startup/kb-startup-runner.ts +159 -0
  174. package/src/modules/workspace/workspace.service.ts +17 -1
  175. package/src/modules/workspace/workspace.tools.ts +35 -0
  176. package/src/shared/domain-errors.ts +58 -0
  177. package/src/shared/git.contract.ts +10 -0
@@ -31,8 +31,26 @@ import type { KbContext } from '../../shared/kb-context.js';
31
31
  import { failureOf, type GitFailure } from '../../shared/git-failure.js';
32
32
  import { redactSecret, urlQuerySecrets } from '../../shared/redact-secret.js';
33
33
  import { listRootFolders, pickListingBranch } from './git-root-folders.js';
34
+ import { MANAGED_DEFAULT_BRANCH } from './managed-repository.js';
35
+ import { GIT_MODES, type GitHubAppRepository, type RepositorySource } from './repository-source.js';
34
36
  import '../auth/auth.middleware.js'; // Express Request augmentation
35
37
 
38
+ /**
39
+ * What setup needs of the ways a deployment can be given its repository:
40
+ * the source every reader of the repository asks, and the one thing a mode
41
+ * has to have DONE before a save that chooses it may go through.
42
+ */
43
+ export interface RepositorySetup {
44
+ source: RepositorySource;
45
+ /** Create the repository the deployment keeps for itself, if it is not there. Never touches one that is. */
46
+ ensureManaged(initialBranch: string): Promise<void>;
47
+ /**
48
+ * The connection to GitHub through a GitHub App. Absent, that way is not
49
+ * offered, and a save that names it is refused.
50
+ */
51
+ githubApp?: Pick<GitHubAppRepository, 'url' | 'answered' | 'prepare' | 'token' | 'permits'>;
52
+ }
53
+
36
54
  /** Which name a host expects beside the token when none is configured. */
37
55
  const DEFAULT_GIT_USERNAME = 'x-access-token';
38
56
 
@@ -138,8 +156,14 @@ export function createSetupRoutes(
138
156
  checkOidc: (config: OidcConfiguration) => Promise<OidcCheck> = checkOidcConfiguration,
139
157
  /** The issuer-only half, for a test with no application id or secret yet. */
140
158
  checkIssuer: (issuerUrl: string) => Promise<IssuerCheck> = (url) => checkOidcIssuer(url),
159
+ /**
160
+ * The ways a deployment can be given its repository. Absent, there is the
161
+ * one there always was: an address and a token, read from the settings.
162
+ */
163
+ repository?: RepositorySetup,
141
164
  ): express.Router {
142
165
  const router = express.Router();
166
+ const source = repository?.source;
143
167
 
144
168
  /**
145
169
  * Why the last setup-time run of the KB startup phase FAILED, or null. While
@@ -184,7 +208,30 @@ export function createSetupRoutes(
184
208
  };
185
209
  /** The app-gate answer: settings complete AND the KB phase settled clean, whoever ran it. */
186
210
  const kbReady = () =>
187
- isComplete(settings, kb) && kbInit === null && kbInitInFlight === null && bootFailure() === null;
211
+ isComplete(settings, kb, source) && kbInit === null && kbInitInFlight === null && bootFailure() === null;
212
+
213
+ /**
214
+ * Which ways of having a repository this deployment offers and which one
215
+ * it is on, for the setup screen. `mode` is the one IN EFFECT, inferred
216
+ * for a deployment that never chose; `chosen` is the one the settings
217
+ * say, which differs from it between a move and the restart the move
218
+ * owes. The screen opens on what was chosen, says a restart is pending
219
+ * when the two differ, and offers the first of `modes` to a deployment
220
+ * that has none. `pinned` names the variable that chose for the
221
+ * deployment, when one did. Nothing at all from a mount without the
222
+ * choice, which the screen reads as the one way there was.
223
+ */
224
+ const repositoryStatus = () =>
225
+ source
226
+ ? {
227
+ repository: {
228
+ mode: source.mode(),
229
+ chosen: source.chosen(),
230
+ ...(settings.sourceOf('gitMode') === 'env' ? { pinned: 'GIT_MODE' } : {}),
231
+ modes: GIT_MODES.filter((mode) => mode !== 'github-app' || repository?.githubApp !== undefined),
232
+ },
233
+ }
234
+ : {};
188
235
 
189
236
  const requireAdmin: express.RequestHandler = async (req, res, next) => {
190
237
  if (!(await adminAccess.isAdmin(req.userEmail))) {
@@ -219,9 +266,10 @@ export function createSetupRoutes(
219
266
  }
220
267
  res.json({
221
268
  complete: kbReady(),
222
- awaitingRestart: awaitingRestart(settings, kb),
269
+ awaitingRestart: awaitingRestart(settings, kb, source),
223
270
  isAdmin: true,
224
271
  settings: settings.describe(),
272
+ ...repositoryStatus(),
225
273
  oidcVerification: await settings.oidcVerification(),
226
274
  ...(kbInit ? { kbInit } : bootFailure() !== null ? { kbInit: bootFailureKind() } : {}),
227
275
  ...(sync ? { sync: { url: sync.url, last: sync.lastSync() } } : {}),
@@ -265,7 +313,13 @@ export function createSetupRoutes(
265
313
  // save that flips it false→true — whichever field arrives last — is the
266
314
  // one that must run the KB startup phase, regardless of which save
267
315
  // configured the branch model.
268
- const wasComplete = isComplete(settings, kb);
316
+ const wasComplete = isComplete(settings, kb, source);
317
+ // A different question, asked of the GATE: whether anyone is being
318
+ // served. Settings that are answered are not a deployment that works
319
+ // — one whose first clone failed has answered everything and serves
320
+ // nobody.
321
+ const wasServing = kbReady();
322
+ nameBranchesOfManagedRepository(entries);
269
323
  if (!(await connectionHoldsFor(entries, wasComplete, res))) return;
270
324
  const oidc = await signInHoldsFor(entries, res);
271
325
  if (!oidc) return;
@@ -277,7 +331,35 @@ export function createSetupRoutes(
277
331
  if (oidc.record) {
278
332
  await settings.recordOidcVerification(oidc.record.state, oidc.record.credentials);
279
333
  }
280
- const { restartRequired, restartKeys } = await settings.save(entries, req.userId ?? null);
334
+ const saved = await settings.save(entries, req.userId ?? null);
335
+ /**
336
+ * A deployment that was not serving has nothing on the mode it had:
337
+ * the mode chosen takes effect now, and the startup phase below is
338
+ * what a restart would have run. One that WAS serving stays on the
339
+ * mode it is on, working copies and credential alike, until it is
340
+ * started again.
341
+ *
342
+ * SERVING, not answered. The pin protects what is running on the
343
+ * mode in effect, and behind a shut gate nothing is: a first run
344
+ * whose initialisation failed, a boot that found its repository
345
+ * unreachable. Pinned there, the way out of a repository that does
346
+ * not work would be closed — the admin chooses another, the retry
347
+ * below runs against the one that just failed, and the screen asks
348
+ * for a restart that changes nothing.
349
+ */
350
+ if (source && !wasServing) source.takeEffect();
351
+ /**
352
+ * The restart the mode owes is read off the two modes themselves, not
353
+ * off what this save wrote: it is owed for as long as the mode chosen
354
+ * is not the one in effect, whichever save chose it, and no longer
355
+ * once a save chooses the mode in effect back.
356
+ */
357
+ const modePending = source !== undefined && source.chosen() !== source.mode();
358
+ const restartKeys = [
359
+ ...saved.restartKeys.filter((key) => key !== 'gitMode'),
360
+ ...(modePending ? ['gitMode'] : []),
361
+ ];
362
+ const restartRequired = restartKeys.length > 0;
281
363
  /** Whether this save put the stored folder names into the running process. */
282
364
  let layoutApplied = false;
283
365
  /** Whether this save put the stored branch model into the running process. */
@@ -320,7 +402,7 @@ export function createSetupRoutes(
320
402
  * on a re-save of a complete, healthy setup: with the gate open,
321
403
  * sessions may be live and that is no longer a quiet moment.
322
404
  */
323
- if ((!wasComplete || kbInit !== null || bootFailure() !== null) && isComplete(settings, kb)) {
405
+ if ((!wasComplete || kbInit !== null || bootFailure() !== null) && isComplete(settings, kb, source)) {
324
406
  /**
325
407
  * The folder names, applied BEFORE the phase for the same reason as
326
408
  * the branch model above: they are otherwise applied once at boot, so
@@ -360,6 +442,7 @@ export function createSetupRoutes(
360
442
  const raw = initErr instanceof Error ? initErr.message : String(initErr);
361
443
  const msg = redactSecret(raw, [
362
444
  settings.resolve('gitToken'),
445
+ source?.credentials.token() ?? '',
363
446
  ...urlQuerySecrets(settings.resolve('kbRepoUrl')),
364
447
  ]);
365
448
  log.error(`KB initialization failed after setup completed: ${msg}`);
@@ -386,8 +469,9 @@ export function createSetupRoutes(
386
469
  )
387
470
  : restartRequired,
388
471
  complete: kbReady(),
389
- awaitingRestart: awaitingRestart(settings, kb),
472
+ awaitingRestart: awaitingRestart(settings, kb, source),
390
473
  settings: settings.describe(),
474
+ ...repositoryStatus(),
391
475
  oidcVerification: await settings.oidcVerification(),
392
476
  });
393
477
  } catch (err) {
@@ -402,6 +486,101 @@ export function createSetupRoutes(
402
486
  }
403
487
  }
404
488
 
489
+ /**
490
+ * A repository the deployment keeps for itself is new, and empty, and has
491
+ * no branches for anyone to look up: the branch model is ours to name. It
492
+ * is named only when nobody has — an admin's own answer, or one the
493
+ * environment supplies, stands. Every other mode has a repository that
494
+ * already exists, whose branches are asked for, never guessed.
495
+ */
496
+ function nameBranchesOfManagedRepository(entries: Record<string, string>): void {
497
+ if (!source) return;
498
+ const after = settings.resolveAfter(entries);
499
+ if (source.mode(after) !== 'managed') return;
500
+ if (after('defaultBranch') || after('protectedBranches')) return;
501
+ entries.defaultBranch = MANAGED_DEFAULT_BRANCH;
502
+ entries.protectedBranches = MANAGED_DEFAULT_BRANCH;
503
+ }
504
+
505
+ /**
506
+ * The same rule for a repository reached through a GitHub App: a saved
507
+ * connection is one GitHub has accepted for reading and writing, asked
508
+ * with the token git will present, which is the installation's.
509
+ *
510
+ * That token is also what keeps the repository's NAME honest. It is the
511
+ * one part of this connection an admin types, and a name the installation
512
+ * does not reach is refused here by GitHub, not by a list of ours.
513
+ *
514
+ * The repository has just said what it calls its trunk, so a deployment
515
+ * with no branch model is given that one: the same answer the setup
516
+ * screen fills in for a repository reached by its address.
517
+ */
518
+ async function githubConnectionHoldsFor(
519
+ setup: RepositorySetup,
520
+ entries: Record<string, string>,
521
+ after: (key: string) => string,
522
+ wasComplete: boolean,
523
+ res: express.Response,
524
+ ): Promise<boolean> {
525
+ const refuse = (problem: string, field = 'githubRepository') => {
526
+ res.status(400).json({ error: problem, problems: { [field]: problem } });
527
+ return false;
528
+ };
529
+ const app = setup.githubApp;
530
+ if (!app) return refuse('This deployment cannot connect to GitHub through a GitHub App.', 'gitMode');
531
+ if (!app.answered(after)) {
532
+ return refuse(
533
+ after('githubRepository')
534
+ ? 'Connect GitHub before choosing a repository.'
535
+ : 'Choose the repository the knowledge base lives in.',
536
+ );
537
+ }
538
+ const url = app.url(after);
539
+ const unanswered =
540
+ validateBranchModel({ defaultBranch: after('defaultBranch'), protectedBranches: after('protectedBranches') }) !== null;
541
+ // Against what is CHOSEN now, not what is in effect: a repository that
542
+ // was proven by the save that chose it is not proven again by the next.
543
+ const stored = (key: string) => settings.resolve(key);
544
+ const changes = setup.source.chosen() !== 'github-app' || url !== setup.source.url(stored);
545
+ if (!changes && (wasComplete || unanswered)) return true;
546
+ // BEFORE GitHub is asked anything with the installation token: that
547
+ // token reaches every repository the installation covers, and would
548
+ // answer for one the person connecting it could never have written to.
549
+ if (!app.permits(after('githubRepository'), after)) {
550
+ log.warn('a repository was named that the person who connected GitHub could not push to');
551
+ return refuse(
552
+ 'Choose a repository your own GitHub account can write to. If this one should be, press “Refresh the list” so it is brought up to date.',
553
+ );
554
+ }
555
+ try {
556
+ await app.prepare({ asked: true });
557
+ } catch (err) {
558
+ log.error('GitHub gave no token for the installation:', { detail: err instanceof Error ? err.message : String(err) });
559
+ }
560
+ const token = app.token();
561
+ if (!token) {
562
+ return refuse('GitHub gave this deployment no access. The app may have been uninstalled: connect it again.', 'gitMode');
563
+ }
564
+ const check = await checkConnection({ url, token, username: DEFAULT_GIT_USERNAME });
565
+ if (check.outcome !== 'connected') {
566
+ return refuse(
567
+ check.outcome === 'read-only'
568
+ ? 'The GitHub App can read that repository but not write to it. Grant it write access to the repository’s contents.'
569
+ : check.reason === 'unreachable'
570
+ ? 'GitHub could not be reached. Try again shortly.'
571
+ : 'The GitHub App cannot reach that repository. Add the repository to the app’s installation on GitHub.',
572
+ );
573
+ }
574
+ if (!after('defaultBranch') && !after('protectedBranches')) {
575
+ const trunk = check.defaultBranch || check.branches[0] || (check.empty ? MANAGED_DEFAULT_BRANCH : '');
576
+ if (trunk) {
577
+ entries.defaultBranch = trunk;
578
+ entries.protectedBranches = trunk;
579
+ }
580
+ }
581
+ return true;
582
+ }
583
+
405
584
  /**
406
585
  * A SAVED CONNECTION IS ONE THE HOST HAS ACCEPTED FOR READING AND WRITING.
407
586
  *
@@ -427,6 +606,25 @@ export function createSetupRoutes(
427
606
  res: express.Response,
428
607
  ): Promise<boolean> {
429
608
  const after = settings.resolveAfter(entries);
609
+ if (repository && repository.source.mode(after) === 'managed') {
610
+ // Nothing to prove to a host: the repository is the deployment's own.
611
+ // What has to hold is that it EXISTS before the save that points
612
+ // everything at it, so a disk that cannot take it refuses the choice
613
+ // instead of failing the first clone.
614
+ try {
615
+ await repository.ensureManaged(after('defaultBranch') || MANAGED_DEFAULT_BRANCH);
616
+ return true;
617
+ } catch (err) {
618
+ log.error('the managed repository could not be created:', { err });
619
+ const problem =
620
+ 'The repository could not be created on this deployment’s storage. Check that its backups folder is writable, or connect a repository of your own.';
621
+ res.status(400).json({ error: problem, problems: { gitMode: problem } });
622
+ return false;
623
+ }
624
+ }
625
+ if (repository && repository.source.mode(after) === 'github-app') {
626
+ return githubConnectionHoldsFor(repository, entries, after, wasComplete, res);
627
+ }
430
628
  const now: RepositoryConnection = {
431
629
  url: settings.resolve('kbRepoUrl'),
432
630
  token: settings.resolve('gitToken'),
@@ -763,8 +961,12 @@ export function createSetupRoutes(
763
961
  * deployment signs in perfectly well without it, and gating on it would lock
764
962
  * an admin out of the screen where they would set it up.
765
963
  */
766
- export function settingsAnswered(settings: DeploymentSettingsService): boolean {
767
- const kb = Boolean(settings.resolve('kbRepoUrl') && settings.resolve('gitToken'));
964
+ export function settingsAnswered(
965
+ settings: DeploymentSettingsService,
966
+ /** What answers for the repository. Absent: an address and a token, the one way there was. */
967
+ source?: Pick<RepositorySource, 'answered'>,
968
+ ): boolean {
969
+ const kb = source ? source.answered() : Boolean(settings.resolve('kbRepoUrl') && settings.resolve('gitToken'));
768
970
  const branches =
769
971
  validateBranchModel({
770
972
  defaultBranch: settings.resolve('defaultBranch'),
@@ -791,14 +993,16 @@ export function settingsAnswered(settings: DeploymentSettingsService): boolean {
791
993
  export function isComplete(
792
994
  settings: DeploymentSettingsService,
793
995
  kb: Pick<KbContext, 'isBranchModelConfigured'>,
996
+ source?: Pick<RepositorySource, 'answered'>,
794
997
  ): boolean {
795
- return settingsAnswered(settings) && kb.isBranchModelConfigured();
998
+ return settingsAnswered(settings, source) && kb.isBranchModelConfigured();
796
999
  }
797
1000
 
798
1001
  /** Answered, but not yet in effect: everything is stored, the process is stale. */
799
1002
  export function awaitingRestart(
800
1003
  settings: DeploymentSettingsService,
801
1004
  kb: Pick<KbContext, 'isBranchModelConfigured'>,
1005
+ source?: Pick<RepositorySource, 'answered'>,
802
1006
  ): boolean {
803
- return settingsAnswered(settings) && !kb.isBranchModelConfigured();
1007
+ return settingsAnswered(settings, source) && !kb.isBranchModelConfigured();
804
1008
  }
@@ -27,15 +27,19 @@ const FOLDER = 'Skills/Eng/deploy';
27
27
 
28
28
  async function makeHarness(listBranches: IWorkflowService['listBranches']) {
29
29
  const workflow = {
30
- listChangeRequestsAuthoredBy: vi.fn(async () => []),
30
+ listChangeRequests: vi.fn(async () => []),
31
31
  listBranches: vi.fn(listBranches),
32
32
  createBranch: vi.fn(async () => ({ name: 'x', isDefault: false, isProtected: false })),
33
+ deleteBranch: vi.fn(async () => undefined),
33
34
  commitChanges: vi.fn(async () => null),
34
35
  openChangeRequest: vi.fn(async () => ({ number: 42 })),
36
+ getChangeRequestDetail: vi.fn(async () => ({ body: '' })),
35
37
  } as unknown as IWorkflowService;
36
38
  const workspaceService = {
37
39
  getOrCreateForBranch: vi.fn(async (branch: string) => ({ id: workspaceIdForBranch(branch) })),
38
40
  readFile: vi.fn(async () => '---\n---\nwrite:\n - Eve <eve@x.io>\n'),
41
+ readFileAtRef: vi.fn(async () => null),
42
+ ensureRemotesFetched: vi.fn(async () => undefined),
39
43
  writeFile: vi.fn(async () => undefined),
40
44
  } as unknown as WorkspaceService;
41
45
  const accessControl = { canWrite: vi.fn(async () => false) } as unknown as IAccessControl;
@@ -3,16 +3,12 @@ import { logger } from '../../shared/logging.js';
3
3
 
4
4
  const log = logger('skills');
5
5
  import '../auth/auth.middleware.js'; // Express Request.userId / userEmail augmentation
6
- import {
7
- joinBranchFor,
8
- type AuthUser,
9
- type IWorkflowService,
10
- } from '@bevel-software/platform-shared';
6
+ import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared';
11
7
  import type { IAccessControl } from '../access/access-control.interface.js';
12
- import { spliceGrant } from '../access-model/access-splice.js';
8
+ import { folderTarget } from '../access/access-requests.contract.js';
9
+ import { AccessRequestsService } from '../access/access-requests.service.js';
13
10
  import { WorkflowDomainError } from '../../shared/domain-errors.js';
14
11
  import { domainErrorBody } from '../../shared/http-errors.js';
15
- import { isAbsence } from '../../shared/fs.contract.js';
16
12
  import type { KbContext } from '../../shared/kb-context.js';
17
13
  import type { WorkspaceService } from '../workspace/workspace.service.js';
18
14
  import type { JoinRequestsService } from '../plugins/join-requests.service.js';
@@ -28,13 +24,11 @@ import type { ISkillService } from './skills.contract.js';
28
24
  *
29
25
  * Why it exists: linking a skill into a plugin needs write on the skill's
30
26
  * rules (the link grants the plugin's principal there), and a plugin manager
31
- * often has no say over a skill some other scope owns. The request is a plain
32
- * change request whose branch splices `write: <requester>` into the skill
33
- * folder's `access.md`; the skill's editors see its proposals and accept
34
- * through the ordinary grant path, and it retires itself once the default
35
- * branch carries the grant (`JoinRequestsService`, keyed on the skill FOLDER
36
- * path rather than a plugin name — the branch-name convention takes any
37
- * exact string).
27
+ * often has no say over a skill some other scope owns. The request is opened
28
+ * by the shared `AccessRequestsService` — the same one the Manage access
29
+ * dialog's Request access uses — keyed on the skill FOLDER path, so this
30
+ * button and a Can edit request on that same folder are ONE request, shown in
31
+ * the skill page's banner and in the folder's dialog alike.
38
32
  *
39
33
  * Fail-closed like the skill catalog: a skill the caller cannot read answers
40
34
  * as unknown, and the editors' listing answers `[]` to anyone else.
@@ -49,10 +43,18 @@ export function createSkillAccessRequestRoutes(deps: {
49
43
  resolveUser: (req: express.Request) => Promise<AuthUser | null>;
50
44
  }): express.Router {
51
45
  const { skillService, accessControl, workflow, workspaceService, joinRequests, kb, resolveUser } = deps;
52
- const { kbDirName } = kb;
53
46
  const router = express.Router();
54
47
  const wsId = () => kb.defaultWorkspaceId();
55
48
  const rulesOf = (folder: string) => `${folder}/access.md`;
49
+ // The same opener the Manage access dialog uses, pointed at a skill's
50
+ // folder. Keyed on that folder's PATH, which is what makes this button and
51
+ // a Can edit request on the same folder one request rather than two.
52
+ const requests = new AccessRequestsService({
53
+ workflow,
54
+ workspaceService,
55
+ lifecycle: joinRequests,
56
+ kb,
57
+ });
56
58
 
57
59
  /** The skill the caller may read, by name — or null after answering 404. */
58
60
  async function readableSkill(
@@ -82,65 +84,13 @@ export function createSkillAccessRequestRoutes(deps: {
82
84
  res.status(409).json({ error: 'You can already edit this skill', kind: 'already-writable' });
83
85
  return;
84
86
  }
85
- const branch = joinBranchFor(user.email, folder);
86
- // FRESH: a repeated click must find the request the previous one opened,
87
- // and the cached listing can trail it — a miss here would send the
88
- // retry into a duplicate refusal.
89
- const existing = (await workflow.listChangeRequestsAuthoredBy(user.email, { fresh: true })).find(
90
- (cr) => cr.state === 'open' && cr.branch === branch,
91
- );
92
- if (existing) {
93
- res.json({ ok: true, number: existing.number });
94
- return;
95
- }
96
- // The branch may already exist (a request listed as closed, a retry
97
- // after a failed open). Existence is PROBED, before and — if creation
98
- // fails — after: the race shows up as "already exists" locally or as a
99
- // rejected push when origin got there first, and a message is not a
100
- // contract. A branch that is there is proceeded against; anything else
101
- // is a real failure, and a proposal on a branch that was not made would
102
- // be worse than the error. STRICT: the list proves absence, and a
103
- // listing that could not fetch proves nothing — it throws instead of
104
- // serving refs from before the branch was made (or deleted).
105
- const branchExists = async () =>
106
- (await workflow.listBranches(wsId(), { freshFetch: true, strictFetch: true })).some(
107
- (b) => b.name === branch,
108
- );
109
- if (!(await branchExists())) {
110
- try {
111
- await workflow.createBranch(wsId(), branch, kb.defaultBranch);
112
- } catch (err) {
113
- if (!(await branchExists())) throw err;
114
- }
115
- }
116
- const ws = await workspaceService.getOrCreateForBranch(branch);
117
- const accessPath = `${kbDirName}/${rulesOf(folder)}`;
118
- // Only a PROVEN absence reads as "no rules yet": any other failure must
119
- // not be turned into an empty file that a splice then overwrites.
120
- const current = await workspaceService.readFile(ws.id, accessPath).catch((err: unknown) => {
121
- if (!isAbsence(err)) throw err;
122
- return '';
123
- });
124
- const spliced = spliceGrant(
125
- current,
126
- 'write',
127
- { kind: 'user', email: user.email, displayName: user.name },
128
- { target: 'folder' },
129
- );
130
- if (spliced.changed) {
131
- await workspaceService.writeFile(ws.id, accessPath, spliced.text);
132
- await workflow.commitChanges(ws.id, user, `Request write access to ${name}`);
133
- }
134
- const detail = await workflow.openChangeRequest(ws.id, user, {
135
- sourceBranch: branch,
136
- targetBranch: kb.defaultBranch,
137
- title: `Access request: ${name}`,
138
- description:
139
- `${user.name} asked to edit the skill ${name}. An editor of the skill accepts by ` +
140
- `granting the access this branch proposes; the request closes itself once ` +
141
- `every proposal has landed.`,
87
+ const { number } = await requests.open({
88
+ user,
89
+ target: folderTarget(folder),
90
+ itemName: name,
91
+ level: 'write',
142
92
  });
143
- res.json({ ok: true, number: detail.number });
93
+ res.json({ ok: true, number });
144
94
  } catch (err) {
145
95
  if (err instanceof WorkflowDomainError) {
146
96
  res.status(err.status).json(domainErrorBody(err));
@@ -171,7 +121,7 @@ export function createSkillAccessRequestRoutes(deps: {
171
121
  const ctx = await requireEditor(req, res, () => res.json({ requests: [] }));
172
122
  if (!ctx) return;
173
123
  const crs = await workflow.listChangeRequests();
174
- res.json({ requests: await joinRequests.list(ctx.folder, ctx.folder, crs, ctx.user) });
124
+ res.json({ requests: await joinRequests.list(ctx.folder, folderTarget(ctx.folder), crs, ctx.user) });
175
125
  } catch (err) {
176
126
  log.error('failed to list access requests:', { err });
177
127
  res.status(500).json({ error: 'Failed to list access requests' });
@@ -192,7 +142,7 @@ export function createSkillAccessRequestRoutes(deps: {
192
142
  res.status(404).json({ error: 'Not found' });
193
143
  return;
194
144
  }
195
- res.json({ closed: await joinRequests.reconcile(ctx.folder, ctx.folder, cr, ctx.user) });
145
+ res.json({ closed: await joinRequests.reconcile(ctx.folder, folderTarget(ctx.folder), cr, ctx.user) });
196
146
  } catch (err) {
197
147
  log.error('failed to reconcile an access request:', { err });
198
148
  res.status(500).json({ error: 'Failed to reconcile the request' });
@@ -12,6 +12,7 @@ import type { ICreatorAccess } from '../access-model/creator.js';
12
12
  import type { GroupsIndex } from '../access-model/group-files.js';
13
13
  import type { WorkspaceService } from '../workspace/workspace.service.js';
14
14
  import { isAbsence } from '../../shared/fs.contract.js';
15
+ import { assertBranchProvided } from '../../shared/domain-errors.js';
15
16
  import { branchForWorkspaceId } from '../../shared/workspace-id.js';
16
17
  import type { WorkflowEventBus } from '../workflow/event-bus.js';
17
18
  import type { AuthService } from '../auth/auth.service.js';
@@ -77,6 +78,20 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
77
78
  const { loadActiveGroups } = deps;
78
79
  const fsCache = new Map<string, LocalFilesystem>();
79
80
  const getFilesystem = async (branch: string): Promise<LocalFilesystem> => {
81
+ // THE choke point: every knowledge-base tool resolves its workspace
82
+ // through here, and a `call_tool_chain` call reaches the very same route
83
+ // over loopback — so refusing a branch-less call here refuses it on both
84
+ // paths at once, with no per-tool guard to keep in step.
85
+ //
86
+ // Before ANY workspace work, because both things downstream do with this
87
+ // value are wrong when it is absent: `getOrCreateForUser` defaults a
88
+ // missing branch to the deployment's default (protected) branch, so an
89
+ // omitted argument would silently act on `main`; and a present-but-absent
90
+ // value like the string "undefined" goes to `workspaceIdForBranch`, which
91
+ // makes it a workspace directory of that name and then tries to clone a
92
+ // branch nobody ever pushed. Failing closed here means no workspace is
93
+ // created and no clone is attempted.
94
+ assertBranchProvided(branch);
80
95
  const cached = fsCache.get(branch);
81
96
  if (cached) return cached;
82
97
  const ws = await deps.workspaceService.getOrCreateForUser(user, branch);
@@ -582,7 +582,7 @@ describe('WorkflowService — a refusal clears when a change makes it obsolete',
582
582
  const { svc } = service(
583
583
  store.db,
584
584
  (e) => emitted.push(e as { kind: string }),
585
- {},
585
+ { carryApprovalsForward: vi.fn(async () => 0) },
586
586
  {
587
587
  git: {
588
588
  pull: vi.fn(async () => ({ treeChanged: false })),
@@ -591,6 +591,14 @@ describe('WorkflowService — a refusal clears when a change makes it obsolete',
591
591
  return { kind: 'merged', alreadyUpToDate: false };
592
592
  }),
593
593
  push: vi.fn(async () => undefined),
594
+ hasUnpushedCommits: vi.fn(async () => true),
595
+ pathsChangedBetween: vi.fn(async () => []),
596
+ // The head this test is named for: one sha before the merge,
597
+ // another after it.
598
+ resolvePrShas: vi
599
+ .fn()
600
+ .mockResolvedValueOnce({ baseSha: 'b'.repeat(40), headSha: 'a'.repeat(40) })
601
+ .mockResolvedValue({ baseSha: 'b'.repeat(40), headSha: 'd'.repeat(40) }),
594
602
  } as unknown as Partial<GitService>,
595
603
  prs: {
596
604
  getPrDetail: vi.fn(async () => ({ state: 'open', branch: 'bo/suggestions', base: 'main', viewerCanUpdate: true })),