@bevel-software/platform-core-backend 0.30.0 → 0.32.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 (210) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/core/catalog-revision.js +3 -3
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +2 -1
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +7 -0
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +16 -3
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  16. package/dist/modules/access/access-control.service.js +1 -5
  17. package/dist/modules/access/access-control.service.js.map +1 -1
  18. package/dist/modules/access-model/index.d.ts +14 -0
  19. package/dist/modules/access-model/index.d.ts.map +1 -0
  20. package/dist/modules/access-model/index.js +13 -0
  21. package/dist/modules/access-model/index.js.map +1 -0
  22. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  23. package/dist/modules/audit/agent-audit.service.js +66 -6
  24. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  25. package/dist/modules/audit/audit.contract.d.ts +52 -8
  26. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  27. package/dist/modules/audit/audit.contract.js +37 -1
  28. package/dist/modules/audit/audit.contract.js.map +1 -1
  29. package/dist/modules/audit/audit.routes.d.ts +1 -1
  30. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  31. package/dist/modules/audit/audit.routes.js +15 -13
  32. package/dist/modules/audit/audit.routes.js.map +1 -1
  33. package/dist/modules/audit/request-audit.d.ts +21 -10
  34. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  35. package/dist/modules/audit/request-audit.js +26 -10
  36. package/dist/modules/audit/request-audit.js.map +1 -1
  37. package/dist/modules/auth/account-hooks.d.ts +4 -6
  38. package/dist/modules/auth/account-hooks.d.ts.map +1 -1
  39. package/dist/modules/auth/account-hooks.js +7 -17
  40. package/dist/modules/auth/account-hooks.js.map +1 -1
  41. package/dist/modules/auth/auth.service.d.ts +9 -8
  42. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.service.js +19 -18
  44. package/dist/modules/auth/auth.service.js.map +1 -1
  45. package/dist/modules/database/core-schema.d.ts +43 -4
  46. package/dist/modules/database/core-schema.d.ts.map +1 -1
  47. package/dist/modules/database/core-schema.js +31 -7
  48. package/dist/modules/database/core-schema.js.map +1 -1
  49. package/dist/modules/kb-fs/index.d.ts +1 -0
  50. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  51. package/dist/modules/kb-fs/index.js +1 -0
  52. package/dist/modules/kb-fs/index.js.map +1 -1
  53. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  54. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  55. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  56. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  57. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  58. package/dist/modules/kb-fs/mutex.js +14 -0
  59. package/dist/modules/kb-fs/mutex.js.map +1 -1
  60. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  61. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  62. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  63. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  64. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  65. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  66. package/dist/modules/mcp/mcp.routes.js +67 -0
  67. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  68. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  69. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  70. package/dist/modules/mcp/mcp.service.js +257 -50
  71. package/dist/modules/mcp/mcp.service.js.map +1 -1
  72. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +28 -0
  73. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  74. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +166 -39
  75. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  76. package/dist/modules/mcp/request-context.d.ts +25 -0
  77. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  78. package/dist/modules/mcp/request-context.js +11 -0
  79. package/dist/modules/mcp/request-context.js.map +1 -0
  80. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  81. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  82. package/dist/modules/mcp/shared-definitions.js +117 -0
  83. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  84. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  85. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  86. package/dist/modules/mcp/surface-cache.js +104 -0
  87. package/dist/modules/mcp/surface-cache.js.map +1 -0
  88. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  89. package/dist/modules/tool-helpers/tool-context.js +33 -12
  90. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  91. package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
  92. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  93. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  94. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  95. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  96. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  97. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +10 -1
  98. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  99. package/dist/modules/workflow/agent-tools/change-request-summary.js +2 -0
  100. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  101. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  102. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  103. package/dist/modules/workflow/agent-tools/workflow.tools.js +458 -2
  104. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  105. package/dist/modules/workflow/change-request-hooks.d.ts +78 -0
  106. package/dist/modules/workflow/change-request-hooks.d.ts.map +1 -0
  107. package/dist/modules/workflow/change-request-hooks.js +15 -0
  108. package/dist/modules/workflow/change-request-hooks.js.map +1 -0
  109. package/dist/modules/workflow/git/git.service.d.ts +113 -4
  110. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  111. package/dist/modules/workflow/git/git.service.js +470 -106
  112. package/dist/modules/workflow/git/git.service.js.map +1 -1
  113. package/dist/modules/workflow/git/pull-request.service.d.ts +10 -0
  114. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/git/pull-request.service.js +9 -0
  116. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  117. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  118. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  119. package/dist/modules/workflow/read-on-branch.js +58 -0
  120. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  121. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  122. package/dist/modules/workflow/workflow.routes.js +22 -41
  123. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  124. package/dist/modules/workflow/workflow.service.d.ts +124 -24
  125. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  126. package/dist/modules/workflow/workflow.service.js +326 -48
  127. package/dist/modules/workflow/workflow.service.js.map +1 -1
  128. package/dist/modules/workspace/index.d.ts +2 -0
  129. package/dist/modules/workspace/index.d.ts.map +1 -1
  130. package/dist/modules/workspace/index.js +4 -0
  131. package/dist/modules/workspace/index.js.map +1 -1
  132. package/dist/shared/git.contract.d.ts +7 -0
  133. package/dist/shared/git.contract.d.ts.map +1 -1
  134. package/dist/shared/git.contract.js +9 -0
  135. package/dist/shared/git.contract.js.map +1 -1
  136. package/dist/shared/listener-seam.d.ts +34 -0
  137. package/dist/shared/listener-seam.d.ts.map +1 -0
  138. package/dist/shared/listener-seam.js +42 -0
  139. package/dist/shared/listener-seam.js.map +1 -0
  140. package/dist/shared/no-link-below.d.ts +10 -0
  141. package/dist/shared/no-link-below.d.ts.map +1 -0
  142. package/dist/shared/no-link-below.js +30 -0
  143. package/dist/shared/no-link-below.js.map +1 -0
  144. package/migrations/0018_agent_events_page.sql +11 -0
  145. package/migrations/0019_oauth_token_rotated_at.sql +7 -0
  146. package/migrations/meta/0018_snapshot.json +2429 -0
  147. package/migrations/meta/0019_snapshot.json +2435 -0
  148. package/migrations/meta/_journal.json +15 -1
  149. package/package.json +3 -3
  150. package/src/core/catalog-revision.ts +3 -3
  151. package/src/core/create-core-server.ts +2 -1
  152. package/src/core/create-core-services.ts +21 -2
  153. package/src/index.ts +7 -0
  154. package/src/modules/access/access-control.service.ts +1 -6
  155. package/src/modules/access-model/index.ts +13 -0
  156. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  157. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  158. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  159. package/src/modules/audit/agent-audit.service.ts +66 -6
  160. package/src/modules/audit/audit.contract.ts +62 -10
  161. package/src/modules/audit/audit.routes.ts +14 -12
  162. package/src/modules/audit/request-audit.ts +28 -13
  163. package/src/modules/auth/__tests__/account-deactivation.test.ts +38 -2
  164. package/src/modules/auth/account-hooks.ts +13 -21
  165. package/src/modules/auth/auth.service.ts +19 -18
  166. package/src/modules/database/core-schema.ts +31 -7
  167. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  168. package/src/modules/kb-fs/index.ts +1 -0
  169. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  170. package/src/modules/kb-fs/mutex.ts +16 -0
  171. package/src/modules/mcp/__tests__/bevel-oauth-provider.pg.test.ts +279 -0
  172. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +190 -15
  173. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  174. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  175. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  176. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  177. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  178. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  179. package/src/modules/mcp/mcp.routes.ts +68 -0
  180. package/src/modules/mcp/mcp.service.ts +315 -51
  181. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +214 -48
  182. package/src/modules/mcp/request-context.ts +34 -0
  183. package/src/modules/mcp/shared-definitions.ts +121 -0
  184. package/src/modules/mcp/surface-cache.ts +154 -0
  185. package/src/modules/tool-helpers/tool-context.ts +37 -18
  186. package/src/modules/tool-helpers/tool.contract.ts +8 -0
  187. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  188. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
  189. package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
  190. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  191. package/src/modules/workflow/__tests__/workflow.routes.open-change-request.test.ts +79 -0
  192. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +9 -1
  193. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +508 -5
  194. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +474 -4
  195. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  196. package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
  197. package/src/modules/workflow/agent-tools/workflow.tools.ts +468 -1
  198. package/src/modules/workflow/change-request-hooks.ts +96 -0
  199. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  200. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  201. package/src/modules/workflow/git/git.service.ts +537 -106
  202. package/src/modules/workflow/git/pull-request.service.ts +10 -0
  203. package/src/modules/workflow/read-on-branch.ts +79 -0
  204. package/src/modules/workflow/workflow.routes.ts +21 -39
  205. package/src/modules/workflow/workflow.service.ts +390 -55
  206. package/src/modules/workspace/index.ts +11 -0
  207. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  208. package/src/shared/git.contract.ts +10 -0
  209. package/src/shared/listener-seam.ts +52 -0
  210. package/src/shared/no-link-below.ts +29 -0
@@ -31,6 +31,7 @@ import { promises as fs } from 'node:fs';
31
31
  import { execFile } from 'node:child_process';
32
32
  import { promisify } from 'node:util';
33
33
  import { isFolderPlaceholder, folderPlaceholderPath, isPlatformRestoreShape, } from '@bevel-software/platform-shared';
34
+ import { readableAtCommits, ReadOnBranchDeniedError } from './read-on-branch.js';
34
35
  import { and, desc, eq, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm';
35
36
  import { changeRequests } from '../database/schema.js';
36
37
  import { pgErrorCode } from '../database/pg-error.js';
@@ -39,12 +40,13 @@ import { workspaceIdForBranch, branchForWorkspaceId } from '../../shared/workspa
39
40
  import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
40
41
  import { describeSyncFailure, sanitizeError } from './sanitize-error.js';
41
42
  import { WorkflowHooks } from './workflow-hooks.js';
43
+ import { ChangeRequestHooks, } from './change-request-hooks.js';
42
44
  import { WorkspaceMutex } from '../kb-fs/mutex.js';
43
45
  import { assertValidBranchName } from '../kb-fs/branch-name.js';
44
46
  import { canonicalEmail, hashEmail } from '../../shared/email-identity.js';
45
47
  import { BranchAuthorshipError, BranchDeleteBlockedError, BranchNotFoundError, ChangeRequestConflictsError, DuplicateChangeRequestError, OpenChangeRequestBlocksMergeError, ProtectedBranchError, RolesYamlPreservationError, PullRebaseConflictError, PushNeedsAgentResolutionError, RemoteBranchGoneError, WorkflowDomainError, WorkflowValidationError, } from '../../shared/domain-errors.js';
46
48
  import { RECOVERY_BOT_EMAIL, RECOVERY_BOT_NAME } from './recovery-bot.js';
47
- import { AccessDeniedError } from '../access-model/access-errors.js';
49
+ import { AccessDeniedError, EVERYONE_CANONICAL } from '../access-model/index.js';
48
50
  import { printable } from '../../shared/printable.js';
49
51
  const execFileAsync = promisify(execFile);
50
52
  /** The partial unique index that enforces one OPEN change request per (source, target) pair. */
@@ -162,6 +164,19 @@ export function formatAffectedOwnersBlock(paths, resolved, maxListed = MAX_AFFEC
162
164
  }
163
165
  return out.join('\n');
164
166
  }
167
+ /**
168
+ * The `## Affected owners` markdown block appended to the CR body, from the
169
+ * eligibility already resolved. The per-path list is capped (see
170
+ * {@link formatAffectedOwnersBlock}) so the body stays within the
171
+ * `gh pr create --body` arg limit on large changesets. The empty string when
172
+ * nothing was resolvable — keeps the body clean rather than printing a stub
173
+ * heading.
174
+ */
175
+ function buildAffectedOwnersBlock(paths, resolved) {
176
+ if (!resolved || resolved.size === 0)
177
+ return '';
178
+ return formatAffectedOwnersBlock(paths, resolved);
179
+ }
165
180
  /** Git commit subjects are capped at 200 characters (`GitService.commitFile`). */
166
181
  const MAX_COMMIT_SUBJECT = 200;
167
182
  /**
@@ -196,6 +211,8 @@ export class WorkflowService {
196
211
  events;
197
212
  fileChanges;
198
213
  hooks;
214
+ changeRequestHooks;
215
+ accounts;
199
216
  /**
200
217
  * Concrete `PullRequestService` (not the interface) so the facade can call
201
218
  * `invalidateDetailCache` after a mutation lands — the legacy routes do
@@ -257,7 +274,20 @@ export class WorkflowService {
257
274
  * consult; the default keeps test constructions (which don't exercise
258
275
  * hooks) unchanged.
259
276
  */
260
- hooks = new WorkflowHooks()) {
277
+ hooks = new WorkflowHooks(),
278
+ /**
279
+ * The seam a deployment's notification extends when a request is opened
280
+ * (see `change-request-hooks.ts`). The composition root passes the SAME
281
+ * instance it exposes as `changeRequestHooks`; the default registers
282
+ * nobody, so a test construction notifies nobody and says so.
283
+ */
284
+ changeRequestHooks = new ChangeRequestHooks(),
285
+ /**
286
+ * Who turns the addresses the access model names into accounts, for the
287
+ * opened event's `approvers`. Absent (a harness without accounts), every
288
+ * approver counts as unresolved.
289
+ */
290
+ accounts) {
261
291
  this.db = db;
262
292
  this.git = git;
263
293
  this.prs = prs;
@@ -271,6 +301,8 @@ export class WorkflowService {
271
301
  this.events = events;
272
302
  this.fileChanges = fileChanges;
273
303
  this.hooks = hooks;
304
+ this.changeRequestHooks = changeRequestHooks;
305
+ this.accounts = accounts;
274
306
  // The type already requires it; this holds for a caller the compiler did
275
307
  // not see (plain JavaScript, a positional slip in a harness).
276
308
  if (!changeGate || typeof changeGate.assertMayChange !== 'function') {
@@ -340,19 +372,25 @@ export class WorkflowService {
340
372
  * opposite directions (`A -> B` and `B -> A`), and nothing is ever held
341
373
  * while waiting for the other key.
342
374
  *
343
- * A PROTECTED target's key is NOT reserved. No path in the system can
344
- * delete a protected branch (`git.deleteBranch` throws
375
+ * A PROTECTED branch's key is NOT reserved, at either end. No path in the
376
+ * system can delete a protected branch (`git.deleteBranch` throws
345
377
  * `ProtectedBranchError` before any lock, `retireMergedSourceBranch`
346
378
  * returns early on one), so the key would exclude nothing. It would only
347
- * cost: nearly every request targets the protected default branch, so one
348
- * shared key there would serialize every open on the hottest path for the
349
- * duration of a fetch + merge + push + diff. The source needs no such
350
- * carve-out; `openChangeRequest` refuses protected sources before locking.
379
+ * cost: nearly every request targets the protected default branch, and
380
+ * every draft refreshes FROM it (`updateBranch`, the merge tool's sync),
381
+ * so one shared key there would serialize every open on the hottest path for the
382
+ * duration of a fetch + merge + push + diff. A protected SOURCE takes no
383
+ * key for the same reason: `openChangeRequest` refuses protected sources
384
+ * before locking, and a draft refreshing from the default branch is
385
+ * exactly a protected source.
351
386
  */
352
387
  runOnBranchPair(source, target, fn) {
353
- const keys = [`branch:${source}`];
354
- if (!this.kb.isProtectedBranch(target))
355
- keys.push(`branch:${target}`);
388
+ // A PROTECTED branch's key is not reserved at EITHER end (see above):
389
+ // nothing can delete one, so the key would exclude nothing and only
390
+ // serialize every draft that refreshes from the default branch behind
391
+ // every other. The same branch at both ends (a draft syncing its own
392
+ // shared state) takes its one key.
393
+ const keys = [...new Set([source, target].filter((b) => !this.kb.isProtectedBranch(b)).map((b) => `branch:${b}`))];
356
394
  return this.branchLifecycle.runAll(keys, fn);
357
395
  }
358
396
  /**
@@ -798,7 +836,16 @@ export class WorkflowService {
798
836
  * only code that can observe it under the workspace mutex.
799
837
  */
800
838
  async pullWorkspace(workspaceId, opts = {}) {
801
- const { treeChanged } = await this.git.pull(workspaceId, opts);
839
+ return this.pullWorkspaceVia(() => this.git.pull(workspaceId, opts), workspaceId);
840
+ }
841
+ /**
842
+ * `pullWorkspace` over the pull it is given: the workspace's own
843
+ * (`git.pull`, which takes the workspace's reservation) or one a caller
844
+ * already holding that reservation hands in (a merge's publish). The
845
+ * announcement is the same either way.
846
+ */
847
+ async pullWorkspaceVia(pull, workspaceId) {
848
+ const { treeChanged } = await pull();
802
849
  const defaultBranch = this.kb.defaultBranch;
803
850
  if (treeChanged && branchForWorkspaceId(workspaceId) === defaultBranch) {
804
851
  this.events?.emit({ kind: 'fs-tree-changed', workspaceId, branch: defaultBranch });
@@ -1063,8 +1110,8 @@ export class WorkflowService {
1063
1110
  listChangesForFile(workspaceId, path, limit) {
1064
1111
  return this.git.logForFile(workspaceId, path, limit);
1065
1112
  }
1066
- compareFile(workspaceId, path, fromBranch, toBranch) {
1067
- return this.git.diffFileBetweenBranches(workspaceId, path, fromBranch, toBranch);
1113
+ compareFile(workspaceId, path, fromBranch, toBranch, authorize) {
1114
+ return this.git.diffFileBetweenBranches(workspaceId, path, fromBranch, toBranch, authorize);
1068
1115
  }
1069
1116
  fileAtForkPoint(workspaceId, baseBranch, sha, path) {
1070
1117
  return this.git.readFileAtForkPoint(workspaceId, baseBranch, sha, path);
@@ -1574,7 +1621,20 @@ export class WorkflowService {
1574
1621
  async pushWithRecovery(workspaceId, branch, targetPath, user, opts) {
1575
1622
  // No explicit `undefined` third argument when there are no options: the
1576
1623
  // call shape stays the bare `push(workspaceId, user)` every caller made.
1577
- const push = () => opts ? this.git.push(workspaceId, user, opts) : this.git.push(workspaceId, user);
1624
+ return this.pushWithRecoveryVia({
1625
+ push: () => (opts ? this.git.push(workspaceId, user, opts) : this.git.push(workspaceId, user)),
1626
+ pull: () => this.pullWorkspace(workspaceId, { preserveMerges: true }),
1627
+ }, workspaceId, branch, targetPath, user);
1628
+ }
1629
+ /**
1630
+ * `pushWithRecovery`'s policy over the two moves it is made of — the push,
1631
+ * and the pull-rebase that keeps a stranded merge a merge — so that a
1632
+ * caller already holding the workspace's reservation (a merge publishing
1633
+ * itself, `updateBranch`) runs the SAME policy over its own unlocked moves,
1634
+ * and a push the host refuses answers one way there too.
1635
+ */
1636
+ async pushWithRecoveryVia(ops, workspaceId, branch, targetPath, user) {
1637
+ const push = ops.push;
1578
1638
  try {
1579
1639
  await push();
1580
1640
  this.noteGitSyncOk(workspaceId, branch);
@@ -1596,7 +1656,7 @@ export class WorkflowService {
1596
1656
  // with whatever push of the branch comes next — a save, a share —
1597
1657
  // and a plain rebase would flatten it into cherry-picks. With no
1598
1658
  // merge among the local commits the two spellings replay the same.
1599
- await this.pullWorkspace(workspaceId, { preserveMerges: true });
1659
+ await ops.pull();
1600
1660
  try {
1601
1661
  await push();
1602
1662
  }
@@ -1830,6 +1890,10 @@ export class WorkflowService {
1830
1890
  * account, so no hidden body marker). The DB-assigned number is the CR id.
1831
1891
  * 6. Re-fetch the full detail via the PR service so the response shape
1832
1892
  * matches every other CR endpoint.
1893
+ * 7. Tell the deployment's change-request listeners (see
1894
+ * `change-request-hooks.ts`); `notified` in the answer says which
1895
+ * channels reached the approvers. Once per request, here, whether a
1896
+ * person or an agent opened it — both take this road.
1833
1897
  */
1834
1898
  async openChangeRequest(workspaceId, user, input) {
1835
1899
  if (!input.sourceBranch)
@@ -1855,7 +1919,13 @@ export class WorkflowService {
1855
1919
  // its branches is retired inside the window, and holding only the source
1856
1920
  // left a `Y -> X` request open to X being deleted under it — the very
1857
1921
  // failure the widened guards exist to prevent.
1858
- return this.runOnBranchPair(input.sourceBranch, input.targetBranch, async () => {
1922
+ //
1923
+ // The deployment's listeners are told AFTER the locks are released: they
1924
+ // are awaited (the answer's `notified` is true to what happened), and a
1925
+ // slow mailer must hold up this answer alone, never a delete or a merge
1926
+ // on either branch. So the locked part answers what it made — the detail,
1927
+ // or the refusal — and the telling follows.
1928
+ const outcome = await this.runOnBranchPair(input.sourceBranch, input.targetBranch, async () => {
1859
1929
  // Uniqueness rule — A→B blocks A→B (the spec is explicit that B→A in
1860
1930
  // parallel is still allowed). `listOpenPrs` is cached for 30s so we
1861
1931
  // force-fresh to catch a CR that opened just now.
@@ -1895,7 +1965,10 @@ export class WorkflowService {
1895
1965
  // (which only populates after the PR exists). Three-dot range
1896
1966
  // matches what `gh pr create` will eventually show as the diff.
1897
1967
  const touchedPaths = await this.listChangedPathsBetweenBranches(cwd, input.targetBranch, input.sourceBranch);
1898
- const affectedOwnersBlock = await this.buildAffectedOwnersBlock(workspaceId, input.targetBranch, touchedPaths);
1968
+ // Resolved once: the body lists this eligibility, and the opened event
1969
+ // hands the same people to the deployment's listeners.
1970
+ const eligible = await this.eligibleWritersFor(workspaceId, input.targetBranch, touchedPaths);
1971
+ const affectedOwnersBlock = buildAffectedOwnersBlock(touchedPaths, eligible);
1899
1972
  // The change request is a plain DB row now — no provider PR. Author is a
1900
1973
  // real column (no hidden body marker needed, since nothing runs as a shared
1901
1974
  // service account anymore).
@@ -1961,20 +2034,114 @@ export class WorkflowService {
1961
2034
  // refused push never moved — answering the refusal is what the caller
1962
2035
  // needs, not a detail read that may fail on a branch the host lacks.
1963
2036
  this.events?.emit(opened);
1964
- throw pushRefused;
1965
- }
1966
- const detail = await this.prs.getPrDetail(prNumber, {
1967
- fresh: true,
1968
- workspaceId,
1969
- viewerEmail: user.email,
1970
- });
1971
- if (!detail) {
1972
- throw new WorkflowValidationError(`Created change request #${prNumber} but could not fetch its detail`);
2037
+ return { number: prNumber, eligible, detail: null, pushRefused, detailError: null };
1973
2038
  }
2039
+ // The row exists and the push landed: every tab is told, whatever the
2040
+ // read-back below does.
1974
2041
  this.events?.emit(opened);
1975
- return detail;
2042
+ // Reading the detail back is the caller's answer, not the open: a read
2043
+ // that fails is reported, but it is not allowed to cost the reviewers
2044
+ // their message — the request stands, and a retry of the open meets the
2045
+ // duplicate check, so there is no second chance to tell them.
2046
+ let detail = null;
2047
+ let detailError = null;
2048
+ try {
2049
+ detail = await this.prs.getPrDetail(prNumber, {
2050
+ fresh: true,
2051
+ workspaceId,
2052
+ viewerEmail: user.email,
2053
+ });
2054
+ }
2055
+ catch (err) {
2056
+ detailError = err instanceof Error ? err : new Error(String(err));
2057
+ }
2058
+ if (detail)
2059
+ return { number: prNumber, eligible, detail, pushRefused: null, detailError: null };
2060
+ return {
2061
+ number: prNumber,
2062
+ eligible,
2063
+ detail: null,
2064
+ pushRefused: null,
2065
+ detailError: detailError ?? new WorkflowValidationError(`Created change request #${prNumber} but could not fetch its detail`),
2066
+ };
2067
+ });
2068
+ // The request exists in every branch of the outcome — the listeners are
2069
+ // told all the same when the push was refused (`pushPending` says so:
2070
+ // the request is there to find, its commits reach the host with the
2071
+ // branch's next push, and what that is worth to a reviewer is each
2072
+ // listener's call) and when its detail could not be read back. Only
2073
+ // then is the refusal or the failed read the caller's answer.
2074
+ const notified = await this.notifyOpened(outcome.number, input, user, outcome.eligible, outcome.pushRefused !== null);
2075
+ if (outcome.pushRefused)
2076
+ throw outcome.pushRefused;
2077
+ if (outcome.detailError)
2078
+ throw outcome.detailError;
2079
+ return { ...outcome.detail, notified };
2080
+ }
2081
+ /**
2082
+ * Tell the deployment's listeners the request was opened (see
2083
+ * {@link IChangeRequestHooks}); the channels that reached the approvers.
2084
+ * Never throws: a listener's failure is the hooks' to log, and a failed
2085
+ * read of the approvers' accounts hands the listeners none of them, counted
2086
+ * as unresolved.
2087
+ */
2088
+ async notifyOpened(number, input, user, eligible, pushPending) {
2089
+ const { approvers, unresolvedApprovers } = await this.resolveApprovers(eligible);
2090
+ // Only an address a person can open from outside is handed over; the
2091
+ // relative in-app path (no `PUBLIC_FRONTEND_URL`) is the listener's to
2092
+ // complete.
2093
+ const link = this.prs.linkFor(number);
2094
+ return this.changeRequestHooks.runChangeRequestOpened({
2095
+ number,
2096
+ title: input.title.trim(),
2097
+ sourceBranch: input.sourceBranch,
2098
+ targetBranch: input.targetBranch,
2099
+ openedBy: { id: user.id, email: user.email, name: user.name },
2100
+ approvers,
2101
+ unresolvedApprovers,
2102
+ ...(link.urlNote === undefined ? { url: link.url } : {}),
2103
+ pushPending,
1976
2104
  });
1977
2105
  }
2106
+ /**
2107
+ * The distinct people eligible to approve some changed path, as accounts.
2108
+ * Each path's eligibility already expands its roles and groups to the
2109
+ * addresses the access model names (`emails`), so a person is one address
2110
+ * however many paths or roles name them; the addresses are looked up as
2111
+ * live accounts in one read. An address with no account, or with one
2112
+ * switched off, and the built-in `everyone` (anyone signed in may approve;
2113
+ * nobody can be listed for that), are counted, not handed over.
2114
+ */
2115
+ async resolveApprovers(eligible) {
2116
+ const addresses = new Set();
2117
+ let everyone = false;
2118
+ for (const info of eligible?.values() ?? []) {
2119
+ for (const email of info.emails)
2120
+ addresses.add(canonicalEmail(email));
2121
+ if (info.roles.includes(EVERYONE_CANONICAL))
2122
+ everyone = true;
2123
+ }
2124
+ let accounts = new Map();
2125
+ if (addresses.size > 0 && this.accounts) {
2126
+ try {
2127
+ accounts = await this.accounts.findActiveAccountsByEmail([...addresses]);
2128
+ }
2129
+ catch (err) {
2130
+ crLog.warn('the approvers of a change request could not be read as accounts; the listeners are handed none:', { err });
2131
+ }
2132
+ }
2133
+ const approvers = [];
2134
+ let unresolvedApprovers = everyone ? 1 : 0;
2135
+ for (const address of addresses) {
2136
+ const account = accounts.get(address);
2137
+ if (account)
2138
+ approvers.push({ id: account.id, email: account.email, name: account.name });
2139
+ else
2140
+ unresolvedApprovers++;
2141
+ }
2142
+ approvers.sort((a, b) => a.email.localeCompare(b.email));
2143
+ return { approvers, unresolvedApprovers };
2144
+ }
1978
2145
  /**
1979
2146
  * Refresh an existing change request from its target — re-runs the
1980
2147
  * `target → source` auto-merge so the CR diff reflects what the merge
@@ -3496,8 +3663,8 @@ export class WorkflowService {
3496
3663
  // while they are held: a bare read would let a person open the request
3497
3664
  // mid-merge and the agent would land its content anyway, which is the one
3498
3665
  // thing this tool exists to prevent. `runOnBranchPair` drops a protected
3499
- // target's key; the source key is held either way, and every competing
3500
- // request from this source takes it too.
3666
+ // branch's key at either end; a draft source's key is held, and every
3667
+ // competing request from this source takes it too.
3501
3668
  return this.runOnBranchPair(sourceBranch, targetBranch, async () => {
3502
3669
  // A person merges a change request. Only the request's OWN direction is
3503
3670
  // blocked: the target merged into the source is how a draft under review
@@ -3896,31 +4063,142 @@ export class WorkflowService {
3896
4063
  }
3897
4064
  }
3898
4065
  /**
3899
- * Build the `## Affected owners` markdown block that gets appended to
3900
- * the CR body. Lists the eligible approvers (roles + named users) per
3901
- * changed path, per the access tree on `origin/<targetBranch>` — the same
3902
- * tree the approval gate uses server-side, so what the body advertises
3903
- * matches who can actually approve at merge time. The per-path list is
3904
- * capped (see {@link formatAffectedOwnersBlock}) so the body stays within
3905
- * the `gh pr create --body` arg limit on large changesets.
3906
- *
3907
- * Returns the empty string when there are no touched paths or no
3908
- * resolvable eligibility — keeps the body clean rather than printing
3909
- * a stub heading.
4066
+ * The eligible approvers (roles + named users, and the addresses they
4067
+ * expand to) per changed path, per the access tree on
4068
+ * `origin/<targetBranch>` — the same tree the approval gate uses
4069
+ * server-side, so what the body advertises and the listeners are told
4070
+ * matches who can actually approve at merge time. Null when there are no
4071
+ * touched paths or no resolvable eligibility: a missing affected-owners
4072
+ * block is cosmetic, not a reason to abort the open.
3910
4073
  */
3911
- async buildAffectedOwnersBlock(workspaceId, targetBranch, paths) {
4074
+ async eligibleWritersFor(workspaceId, targetBranch, paths) {
3912
4075
  if (paths.length === 0)
3913
- return '';
3914
- let resolved = null;
4076
+ return null;
3915
4077
  try {
3916
- resolved = await this.accessControl.eligibleWritersForPathsAtRef(workspaceId, `origin/${targetBranch}`, paths);
4078
+ return await this.accessControl.eligibleWritersForPathsAtRef(workspaceId, `origin/${targetBranch}`, paths);
3917
4079
  }
3918
4080
  catch (err) {
3919
4081
  log.warn('eligibleWritersForPathsAtRef failed:', { err });
4082
+ return null;
3920
4083
  }
3921
- if (!resolved || resolved.size === 0)
3922
- return '';
3923
- return formatAffectedOwnersBlock(paths, resolved);
4084
+ }
4085
+ async updateBranch(workspaceId, user, opts = {}) {
4086
+ const branch = branchForWorkspaceId(workspaceId);
4087
+ const from = opts.from ?? branch;
4088
+ // A resolution writes on the draft: it is somebody's to gate, and a
4089
+ // caller that brings none gates nothing (closed) — the type says so for
4090
+ // a compiled caller, this for any other.
4091
+ if ((opts.resolutions?.length ?? 0) > 0 && typeof opts.validateWrite !== 'function') {
4092
+ throw new Error('updateBranch: resolutions need a `validateWrite` to be asked before the merge');
4093
+ }
4094
+ if (this.kb.isProtectedBranch(branch)) {
4095
+ throw new WorkflowDomainError(`"${branch}" is protected: it is changed through change requests, never merged into directly.`, 403, { kind: 'protected-update-target', branch });
4096
+ }
4097
+ // The one merge a person makes: a change request's. An update that would
4098
+ // land an OPEN request from `from` into this draft is that merge by
4099
+ // another name, and `mergeBranch`'s gate refuses it here too — asked once
4100
+ // before waiting, and again under the locks below, where the answer
4101
+ // counts. (The other direction, the request's target into its source, is
4102
+ // how a draft under review stays current, and is never blocked.)
4103
+ if (from !== branch) {
4104
+ const openBefore = await this.openRequestBlockingMerge(from, branch);
4105
+ if (openBefore !== null)
4106
+ throw new OpenChangeRequestBlocksMergeError(from, branch, openBefore);
4107
+ }
4108
+ // The merge takes what the REMOTE holds of `from`, and the push publishes
4109
+ // the branch: writes still being committed on either would be missed by
4110
+ // the first or swept up by the second. Waited for, as `mergeBranch` waits
4111
+ // — outside the lifecycle locks, which must not be held for seconds of
4112
+ // waiting on a queue.
4113
+ for (const b of from === branch ? [branch] : [from, branch]) {
4114
+ const settled = await this.awaitPendingCommits(b);
4115
+ if (!settled.ok) {
4116
+ const message = settled.needsAttention
4117
+ ? `A commit queued on "${b}" failed and needs a person: ${settled.needsAttention} ` +
4118
+ 'Nothing was merged. Tell the user; update again once it has been resolved.'
4119
+ : `${settled.pending} write(s) on "${b}" are still being committed. Nothing was merged. Retry shortly.`;
4120
+ return {
4121
+ kind: 'pending-commits',
4122
+ branch: b,
4123
+ pending: settled.pending,
4124
+ ...(settled.needsAttention ? { needsAttention: settled.needsAttention } : {}),
4125
+ message,
4126
+ };
4127
+ }
4128
+ }
4129
+ // Under the lifecycle lock of both branches, as `mergeBranch` and
4130
+ // `openChangeRequest` run: the "no open request" answer is only sound
4131
+ // while nobody can open one mid-merge, and a branch being merged into is
4132
+ // not one to delete or recreate meanwhile. A protected `from` takes no
4133
+ // key, and the branch's own shared state (from = branch) takes the one.
4134
+ return this.runOnBranchPair(from, branch, async () => {
4135
+ if (from !== branch) {
4136
+ const open = await this.openRequestBlockingMerge(from, branch);
4137
+ if (open !== null)
4138
+ throw new OpenChangeRequestBlocksMergeError(from, branch, open);
4139
+ }
4140
+ if (!(await this.git.remoteBranchExists(workspaceId, from))) {
4141
+ throw new WorkflowValidationError(`No branch named "${from}" on the shared repository.`);
4142
+ }
4143
+ const resolutions = opts.resolutions ?? [];
4144
+ const subject = from === branch ? `Merge the shared ${branch} into the local one` : `Merge ${from} into ${branch}`;
4145
+ const body = (from === branch
4146
+ ? `The branch had moved on the shared repository while this workspace held commits of its own; both are kept.`
4147
+ : `Brings the latest ${from} into ${branch}.`) +
4148
+ (resolutions.length > 0
4149
+ ? ` Conflicts resolved by ${user.name} <${user.email}> in: ${resolutions.map((r) => r.path).join(', ')}.`
4150
+ : '');
4151
+ // Published inside the merge's own reservation: nothing lands between
4152
+ // the merge commit and its push, and the push is EVERY workflow push's
4153
+ // `pushWithRecovery`, run over the merge's own unlocked moves — a
4154
+ // rejection replays the merge (kept as a merge) onto the moved branch
4155
+ // and pushes again; a replay that conflicts or a host that refuses
4156
+ // answers as every refused share answers, the banner noted and the
4157
+ // typed hand-off thrown, naming the branch (no single file is at issue)
4158
+ // and never a conflicted path. The merge commit stays local for the
4159
+ // next push of the branch to carry.
4160
+ return this.git.mergeIntoBranch(workspaceId, branch, from, user, {
4161
+ subject,
4162
+ body,
4163
+ resolutions,
4164
+ publish: (ops) => this.pushWithRecoveryVia({ push: ops.push, pull: () => this.pullWorkspaceVia(ops.pullRebase, workspaceId) }, workspaceId, branch, branch, user),
4165
+ // A resolution writes a file on the draft from what both branches
4166
+ // hold of it. It is authorized inside the merge's reservation, after
4167
+ // the fetch and before the merge touches the checkout — the draft's
4168
+ // tree clean at its own commit, so the rules read from it are the
4169
+ // draft's, never a rule file mid-merge: the caller must read the
4170
+ // file at the draft's commit and at the source commit the merge is
4171
+ // of, and may change it on the draft by the one rule every draft
4172
+ // write takes — the change gate `acquireLock` asks, not a write
4173
+ // permission a draft never requires.
4174
+ authorizeResolutions: async ({ ours, theirs }) => {
4175
+ for (const r of resolutions) {
4176
+ const rel = r.path.startsWith(`${this.kbDirName}/`) ? r.path.slice(this.kbDirName.length + 1) : r.path;
4177
+ // At the two COMMITS the merge is of — pinned by the merge itself,
4178
+ // never at a ref a concurrent fetch can move between this verdict
4179
+ // and the merge — by the one rule the tool shows a conflict by.
4180
+ if (!(await readableAtCommits(this.accessControl, workspaceId, user.email, rel, [ours, theirs]))) {
4181
+ throw new ReadOnBranchDeniedError(rel, from);
4182
+ }
4183
+ // The gate takes the WORKSPACE path, as `acquireLock` hands it
4184
+ // one: a repository-relative path reads as outside the knowledge
4185
+ // base there, which the gate waves through.
4186
+ await this.changeGate.assertMayChange(workspaceId, user.email, `${this.kbDirName}/${rel}`, 'file');
4187
+ // Content the resolution brings takes the caller's pre-disk write
4188
+ // gate, as its file writes do before a byte lands; a side kept
4189
+ // whole is a commit's own bytes and brings none.
4190
+ if (r.content !== undefined && opts.validateWrite)
4191
+ await opts.validateWrite(`${this.kbDirName}/${rel}`, r.content);
4192
+ }
4193
+ },
4194
+ });
4195
+ });
4196
+ }
4197
+ fileAtBranch(workspaceId, path, branch, authorize) {
4198
+ return this.git.readFileAtBranch(workspaceId, path, branch, authorize);
4199
+ }
4200
+ changedPathsBetweenBranches(workspaceId, base, branch) {
4201
+ return this.git.changedPathsForPr(workspaceId, base, branch);
3924
4202
  }
3925
4203
  }
3926
4204
  //# sourceMappingURL=workflow.service.js.map