@bevel-software/platform-core-backend 0.23.0 → 0.25.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 (238) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +17 -3
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +3 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +25 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/core/lifecycle.d.ts +12 -0
  9. package/dist/core/lifecycle.d.ts.map +1 -1
  10. package/dist/core/lifecycle.js +10 -0
  11. package/dist/core/lifecycle.js.map +1 -1
  12. package/dist/core-config.d.ts +16 -0
  13. package/dist/core-config.d.ts.map +1 -1
  14. package/dist/core-config.js +17 -0
  15. package/dist/core-config.js.map +1 -1
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +13 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/modules/access/access.routes.d.ts.map +1 -1
  21. package/dist/modules/access/access.routes.js +4 -1
  22. package/dist/modules/access/access.routes.js.map +1 -1
  23. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
  24. package/dist/modules/access/directory-sync-bot.js +7 -3
  25. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  26. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
  27. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  28. package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
  29. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  30. package/dist/modules/agent-instructions/compose.d.ts +38 -6
  31. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  32. package/dist/modules/agent-instructions/compose.js +39 -6
  33. package/dist/modules/agent-instructions/compose.js.map +1 -1
  34. package/dist/modules/agent-instructions/index.d.ts +2 -1
  35. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  36. package/dist/modules/agent-instructions/index.js +2 -1
  37. package/dist/modules/agent-instructions/index.js.map +1 -1
  38. package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
  39. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
  40. package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
  41. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
  42. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  43. package/dist/modules/audit/agent-audit.service.js +4 -2
  44. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  45. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
  46. package/dist/modules/auth/account-erasure.service.js +57 -16
  47. package/dist/modules/auth/account-erasure.service.js.map +1 -1
  48. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  49. package/dist/modules/auth/auth.service.js +25 -15
  50. package/dist/modules/auth/auth.service.js.map +1 -1
  51. package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
  52. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  53. package/dist/modules/code-mode/code-mode.tool.js +66 -35
  54. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  55. package/dist/modules/database/connection.d.ts +16 -0
  56. package/dist/modules/database/connection.d.ts.map +1 -1
  57. package/dist/modules/database/connection.js +117 -0
  58. package/dist/modules/database/connection.js.map +1 -1
  59. package/dist/modules/database/core-schema.d.ts +296 -96
  60. package/dist/modules/database/core-schema.d.ts.map +1 -1
  61. package/dist/modules/database/core-schema.js +81 -31
  62. package/dist/modules/database/core-schema.js.map +1 -1
  63. package/dist/modules/database/migrate.d.ts +95 -1
  64. package/dist/modules/database/migrate.d.ts.map +1 -1
  65. package/dist/modules/database/migrate.js +390 -2
  66. package/dist/modules/database/migrate.js.map +1 -1
  67. package/dist/modules/kb-fs/locking-filesystem.d.ts +17 -0
  68. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  69. package/dist/modules/kb-fs/locking-filesystem.js +29 -0
  70. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  71. package/dist/modules/mcp/mcp.service.d.ts +8 -0
  72. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  73. package/dist/modules/mcp/mcp.service.js +38 -8
  74. package/dist/modules/mcp/mcp.service.js.map +1 -1
  75. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
  76. package/dist/modules/plugins/join-request-records.store.js +7 -4
  77. package/dist/modules/plugins/join-request-records.store.js.map +1 -1
  78. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  79. package/dist/modules/tool-auth/external-api-key.service.js +8 -2
  80. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  81. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  82. package/dist/modules/tool-manuals/tool-manuals.service.js +30 -0
  83. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  84. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  85. package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
  86. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  87. package/dist/modules/tool-registry/description-length.d.ts +80 -0
  88. package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
  89. package/dist/modules/tool-registry/description-length.js +108 -0
  90. package/dist/modules/tool-registry/description-length.js.map +1 -0
  91. package/dist/modules/workflow/git/git.service.d.ts +25 -0
  92. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  93. package/dist/modules/workflow/git/git.service.js +40 -1
  94. package/dist/modules/workflow/git/git.service.js.map +1 -1
  95. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  96. package/dist/modules/workflow/pending-commits.service.js +5 -1
  97. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  98. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  99. package/dist/modules/workflow/recovery-bot.js +7 -3
  100. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  101. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  102. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  103. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  104. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  105. package/dist/modules/workflow/workflow.service.js +4 -1
  106. package/dist/modules/workflow/workflow.service.js.map +1 -1
  107. package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
  108. package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
  109. package/dist/modules/workspace/agent-upload.routes.js +210 -0
  110. package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
  111. package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
  112. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
  113. package/dist/modules/workspace/agent-upload.store.js +553 -0
  114. package/dist/modules/workspace/agent-upload.store.js.map +1 -0
  115. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  116. package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
  117. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  118. package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
  119. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  120. package/dist/modules/workspace/startup/steps/template-source.js +46 -4
  121. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  122. package/dist/modules/workspace/upload-limits.d.ts +13 -0
  123. package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
  124. package/dist/modules/workspace/upload-limits.js +13 -0
  125. package/dist/modules/workspace/upload-limits.js.map +1 -0
  126. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  127. package/dist/modules/workspace/workspace.routes.js +1 -1
  128. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  129. package/dist/modules/workspace/workspace.service.d.ts +13 -0
  130. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  131. package/dist/modules/workspace/workspace.service.js +61 -33
  132. package/dist/modules/workspace/workspace.service.js.map +1 -1
  133. package/dist/modules/workspace/workspace.tools.d.ts +11 -9
  134. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  135. package/dist/modules/workspace/workspace.tools.js +570 -134
  136. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  137. package/dist/modules/workspace/write-denial.d.ts +0 -6
  138. package/dist/modules/workspace/write-denial.d.ts.map +1 -1
  139. package/dist/modules/workspace/write-denial.js +0 -6
  140. package/dist/modules/workspace/write-denial.js.map +1 -1
  141. package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
  142. package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
  143. package/dist/modules/workspace/zip-entry-rules.js +154 -0
  144. package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
  145. package/dist/shared/column-crypto.d.ts +194 -0
  146. package/dist/shared/column-crypto.d.ts.map +1 -0
  147. package/dist/shared/column-crypto.js +144 -0
  148. package/dist/shared/column-crypto.js.map +1 -0
  149. package/dist/shared/token-crypto.d.ts.map +1 -1
  150. package/dist/shared/token-crypto.js +25 -1
  151. package/dist/shared/token-crypto.js.map +1 -1
  152. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  153. package/dist/tenancy/static-tenant-source.js +1 -0
  154. package/dist/tenancy/static-tenant-source.js.map +1 -1
  155. package/dist/tenancy/tenant-secrets.d.ts +5 -1
  156. package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
  157. package/dist/tenancy/tenant-secrets.js +4 -0
  158. package/dist/tenancy/tenant-secrets.js.map +1 -1
  159. package/kb-template/AGENTS.md +42 -0
  160. package/migrations/0016_pii_encryption.sql +20 -0
  161. package/migrations/meta/0016_snapshot.json +2327 -0
  162. package/migrations/meta/_journal.json +7 -0
  163. package/package.json +3 -3
  164. package/src/core/__tests__/lifecycle.test.ts +71 -4
  165. package/src/core/create-core-server.ts +21 -3
  166. package/src/core/create-core-services.ts +27 -1
  167. package/src/core/lifecycle.ts +19 -0
  168. package/src/core-config.ts +18 -0
  169. package/src/index.ts +25 -0
  170. package/src/modules/access/__tests__/users-db-double.ts +21 -12
  171. package/src/modules/access/access.routes.ts +4 -1
  172. package/src/modules/access/directory-sync-bot.ts +7 -3
  173. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +8 -4
  174. package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
  175. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
  176. package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
  177. package/src/modules/agent-instructions/compose.ts +50 -7
  178. package/src/modules/agent-instructions/index.ts +12 -0
  179. package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
  180. package/src/modules/audit/agent-audit.service.ts +4 -2
  181. package/src/modules/auth/__tests__/account-deactivation.test.ts +2 -1
  182. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
  183. package/src/modules/auth/__tests__/account.routes.test.ts +6 -3
  184. package/src/modules/auth/__tests__/auth.service.test.ts +3 -1
  185. package/src/modules/auth/account-erasure.service.ts +69 -18
  186. package/src/modules/auth/auth.service.ts +33 -23
  187. package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
  188. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
  189. package/src/modules/code-mode/code-mode.tool.ts +80 -34
  190. package/src/modules/database/__tests__/connection.test.ts +12 -0
  191. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +780 -0
  192. package/src/modules/database/connection.ts +117 -0
  193. package/src/modules/database/core-schema.ts +81 -31
  194. package/src/modules/database/migrate.ts +540 -2
  195. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +93 -0
  196. package/src/modules/kb-fs/locking-filesystem.ts +37 -0
  197. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
  198. package/src/modules/mcp/__tests__/mcp.service.test.ts +47 -7
  199. package/src/modules/mcp/mcp.service.ts +46 -7
  200. package/src/modules/plugins/join-request-records.store.ts +7 -4
  201. package/src/modules/tool-auth/external-api-key.service.ts +8 -2
  202. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +151 -0
  203. package/src/modules/tool-manuals/tool-manuals.service.ts +37 -0
  204. package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
  205. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
  206. package/src/modules/tool-registry/description-length.ts +111 -0
  207. package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
  208. package/src/modules/workflow/git/git.service.ts +47 -1
  209. package/src/modules/workflow/pending-commits.service.ts +5 -1
  210. package/src/modules/workflow/recovery-bot.ts +7 -3
  211. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
  212. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
  213. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  214. package/src/modules/workflow/workflow.service.ts +4 -1
  215. package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
  216. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +24 -14
  217. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
  218. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +77 -1
  219. package/src/modules/workspace/__tests__/workspace.service.test.ts +57 -0
  220. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +39 -36
  221. package/src/modules/workspace/__tests__/workspace.tools.test.ts +196 -46
  222. package/src/modules/workspace/agent-upload.routes.ts +214 -0
  223. package/src/modules/workspace/agent-upload.store.ts +668 -0
  224. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
  225. package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
  226. package/src/modules/workspace/startup/steps/template-source.ts +53 -5
  227. package/src/modules/workspace/upload-limits.ts +12 -0
  228. package/src/modules/workspace/workspace.routes.ts +1 -2
  229. package/src/modules/workspace/workspace.service.ts +63 -37
  230. package/src/modules/workspace/workspace.tools.ts +647 -148
  231. package/src/modules/workspace/write-denial.ts +0 -8
  232. package/src/modules/workspace/zip-entry-rules.ts +173 -0
  233. package/src/shared/__tests__/column-crypto.test.ts +217 -0
  234. package/src/shared/column-crypto.ts +218 -0
  235. package/src/shared/token-crypto.ts +28 -1
  236. package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -0
  237. package/src/tenancy/static-tenant-source.ts +1 -0
  238. package/src/tenancy/tenant-secrets.ts +5 -1
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { testKbContext } from '../../../../__tests__/kb-context.js';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import type { WorkspaceService } from '../../../workspace/workspace.service.js';
9
+ import { WorkflowHooks } from '../../workflow-hooks.js';
10
+ import { GitService } from '../git.service.js';
11
+
12
+ const execFileAsync = promisify(execFile);
13
+
14
+ /**
15
+ * What the log says when a change request's branches cannot be fetched.
16
+ *
17
+ * The fetch is best-effort: when it fails, the refs are resolved from what the
18
+ * working copy already has. A working copy that could not reach the remote at
19
+ * all then reported `unknown branch` for every branch it had never seen, and
20
+ * nothing said the fetch had been refused — the log named a missing branch
21
+ * that was on the remote the whole time.
22
+ */
23
+
24
+ const TRUNK = 'current-company-state';
25
+ const WORKSPACE = 'ws-pr-fetch';
26
+
27
+ async function runGit(cwd: string, args: string[]): Promise<void> {
28
+ await execFileAsync('git', args, {
29
+ cwd,
30
+ env: {
31
+ ...process.env,
32
+ GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 't@x.com',
33
+ GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 't@x.com',
34
+ },
35
+ });
36
+ }
37
+
38
+ let root: string;
39
+ let repo: string;
40
+ let upstream: string;
41
+
42
+ beforeEach(async () => {
43
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'git-pr-fetch-'));
44
+ upstream = path.join(root, 'upstream.git');
45
+ await runGit(root, ['init', '--bare', '-b', TRUNK, upstream]);
46
+ const seed = path.join(root, '.seed');
47
+ await fs.mkdir(seed);
48
+ await runGit(seed, ['init', '-b', TRUNK]);
49
+ await runGit(seed, ['remote', 'add', 'origin', upstream]);
50
+ await fs.writeFile(path.join(seed, 'base.md'), 'base\n');
51
+ await runGit(seed, ['add', '-A']);
52
+ await runGit(seed, ['commit', '-m', 'init']);
53
+ await runGit(seed, ['push', 'origin', TRUNK]);
54
+ // A draft the working copy below has never seen: pushed after it was cloned.
55
+ repo = path.join(root, WORKSPACE, 'knowledge-base');
56
+ await fs.mkdir(path.dirname(repo), { recursive: true });
57
+ await runGit(root, ['clone', upstream, repo]);
58
+ await runGit(seed, ['checkout', '-b', 'alice/feature']);
59
+ await fs.writeFile(path.join(seed, 'new.md'), 'new\n');
60
+ await runGit(seed, ['add', '-A']);
61
+ await runGit(seed, ['commit', '-m', 'feature']);
62
+ await runGit(seed, ['push', 'origin', 'alice/feature']);
63
+ });
64
+
65
+ afterEach(async () => {
66
+ vi.restoreAllMocks();
67
+ await fs.rm(root, { recursive: true, force: true });
68
+ });
69
+
70
+ function service(): GitService {
71
+ const workspaces = {
72
+ getWorkspacePath: async () => path.dirname(repo),
73
+ } as unknown as WorkspaceService;
74
+ return new GitService(workspaces, new WorkflowHooks(), testKbContext());
75
+ }
76
+
77
+ /** Everything the service logged at warn level, as one string per line. */
78
+ function warnings(spy: ReturnType<typeof vi.spyOn>): string[] {
79
+ return spy.mock.calls.map((call) => call.map((part) => (typeof part === 'string' ? part : JSON.stringify(part))).join(' '));
80
+ }
81
+
82
+ describe('a change request whose branches cannot be fetched', () => {
83
+ it('says the fetch failed, and where, when the working copy cannot reach the remote', async () => {
84
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
85
+ // The remote this working copy fetches from is gone from under it.
86
+ await runGit(repo, ['remote', 'set-url', 'origin', path.join(root, 'no-such-remote.git')]);
87
+
88
+ await expect(service().changedPathsForPr(WORKSPACE, TRUNK, 'alice/feature')).rejects.toThrow(
89
+ 'unknown branch: alice/feature',
90
+ );
91
+
92
+ const said = warnings(warn).filter((line) => line.includes('could not fetch change-request branches'));
93
+ expect(said).toHaveLength(1);
94
+ expect(said[0]).toContain(`"${WORKSPACE}"`);
95
+ expect(said[0]).toContain('though it may be on the remote');
96
+ });
97
+
98
+ it('says it once for a list that asks about many requests in one working copy', async () => {
99
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
100
+ await runGit(repo, ['remote', 'set-url', 'origin', path.join(root, 'no-such-remote.git')]);
101
+ const git = service();
102
+
103
+ for (const branch of ['alice/feature', 'bob/other', 'carol/third']) {
104
+ await git.changedPathsForPr(WORKSPACE, TRUNK, branch).catch(() => undefined);
105
+ }
106
+
107
+ expect(warnings(warn).filter((line) => line.includes('could not fetch change-request branches'))).toHaveLength(1);
108
+ });
109
+
110
+ it('says it again once a minute has passed, and not before', async () => {
111
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
112
+ await runGit(repo, ['remote', 'set-url', 'origin', path.join(root, 'no-such-remote.git')]);
113
+ const git = service();
114
+ const said = () => warnings(warn).filter((line) => line.includes('could not fetch change-request branches'));
115
+ // Only the clock the service reads is moved: git itself runs for real.
116
+ const realNow = Date.now.bind(Date);
117
+ let ahead = 0;
118
+ vi.spyOn(Date, 'now').mockImplementation(() => realNow() + ahead);
119
+
120
+ await git.changedPathsForPr(WORKSPACE, TRUNK, 'alice/feature').catch(() => undefined);
121
+ expect(said()).toHaveLength(1);
122
+
123
+ ahead = 30_000;
124
+ await git.changedPathsForPr(WORKSPACE, TRUNK, 'alice/feature').catch(() => undefined);
125
+ expect(said()).toHaveLength(1);
126
+
127
+ ahead = 61_000;
128
+ await git.changedPathsForPr(WORKSPACE, TRUNK, 'alice/feature').catch(() => undefined);
129
+ expect(said()).toHaveLength(2);
130
+ });
131
+
132
+ it('says it again at once for a working copy that reached the remote in between', async () => {
133
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
134
+ const git = service();
135
+ const said = () => warnings(warn).filter((line) => line.includes('could not fetch change-request branches'));
136
+ const broken = path.join(root, 'no-such-remote.git');
137
+
138
+ await runGit(repo, ['remote', 'set-url', 'origin', broken]);
139
+ await git.changedPathsForPr(WORKSPACE, TRUNK, 'alice/feature').catch(() => undefined);
140
+ expect(said()).toHaveLength(1);
141
+
142
+ // It reaches the remote again: what was said about it is over, and nothing
143
+ // of it is kept.
144
+ await runGit(repo, ['remote', 'set-url', 'origin', upstream]);
145
+ expect(await git.changedPathsForPr(WORKSPACE, TRUNK, 'alice/feature')).toEqual(['new.md']);
146
+
147
+ // So a failure right after is a new one, said without waiting out a minute.
148
+ await runGit(repo, ['remote', 'set-url', 'origin', broken]);
149
+ await git.changedPathsForPr(WORKSPACE, TRUNK, 'bob/other').catch(() => undefined);
150
+ expect(said()).toHaveLength(2);
151
+ });
152
+
153
+ it('says nothing when the branch is simply gone from the remote: that is what a retired branch looks like', async () => {
154
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
155
+
156
+ await expect(service().changedPathsForPr(WORKSPACE, TRUNK, 'dave/retired')).rejects.toThrow(
157
+ 'unknown branch: dave/retired',
158
+ );
159
+
160
+ expect(warnings(warn).filter((line) => line.includes('could not fetch change-request branches'))).toEqual([]);
161
+ });
162
+
163
+ it('says nothing when the fetch works, and finds the branch the working copy had never seen', async () => {
164
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
165
+
166
+ expect(await service().changedPathsForPr(WORKSPACE, TRUNK, 'alice/feature')).toEqual(['new.md']);
167
+
168
+ expect(warnings(warn).filter((line) => line.includes('could not fetch change-request branches'))).toEqual([]);
169
+ });
170
+ });
@@ -2959,7 +2959,53 @@ export class GitService implements IGitService {
2959
2959
  'fetch', '--no-write-fetch-head', 'origin',
2960
2960
  `+refs/heads/${baseBranch}:refs/remotes/origin/${baseBranch}`,
2961
2961
  `+refs/heads/${headBranch}:refs/remotes/origin/${headBranch}`,
2962
- ]).catch(() => undefined);
2962
+ ]).then(
2963
+ () => void this.prFetchFailureLoggedAt.delete(cwd),
2964
+ (err: unknown) => this.notePrFetchFailure(cwd, err),
2965
+ );
2966
+ }
2967
+
2968
+ /**
2969
+ * When each working copy's change-request fetch failure was last logged.
2970
+ * Holds only the working copies that are failing NOW: an entry goes the
2971
+ * moment that copy reaches the remote again, so the table never outgrows the
2972
+ * clones that cannot fetch, however many come and go over a process's life.
2973
+ */
2974
+ private readonly prFetchFailureLoggedAt = new Map<string, number>();
2975
+
2976
+ /**
2977
+ * Say WHY a change request's branches could not be fetched, when the reason
2978
+ * is not the one that is expected.
2979
+ *
2980
+ * The fetch stays best-effort, so the caller goes on to resolve the refs
2981
+ * from whatever the clone already has. One failure is ordinary and says
2982
+ * nothing: a branch that is gone from the remote, which is what a retired
2983
+ * source branch looks like. Every OTHER failure is the working copy being
2984
+ * unable to reach the remote at all — a credential it does not have, a host
2985
+ * that does not answer — and swallowed, it resurfaced one step later as
2986
+ * `unknown branch` for a branch that was sitting on the remote the whole
2987
+ * time. A deployment logged that for every open change request on every
2988
+ * list, and nothing in the log said the fetch had been refused.
2989
+ *
2990
+ * Logged once a minute per working copy at most: the list asks once per open
2991
+ * request, and the reason is the same for all of them.
2992
+ */
2993
+ private notePrFetchFailure(cwd: string, err: unknown): void {
2994
+ const message = err instanceof Error ? err.message : String(err);
2995
+ if (isMissingRemoteBranchFailure(message)) {
2996
+ // The remote answered, and said the branch is not there: this working
2997
+ // copy reaches it, so whatever was logged about it before is over.
2998
+ this.prFetchFailureLoggedAt.delete(cwd);
2999
+ return;
3000
+ }
3001
+ const now = Date.now();
3002
+ if (now - (this.prFetchFailureLoggedAt.get(cwd) ?? 0) < 60_000) return;
3003
+ this.prFetchFailureLoggedAt.set(cwd, now);
3004
+ log.warn(
3005
+ `could not fetch change-request branches in the working copy "${path.basename(path.dirname(cwd))}": ` +
3006
+ 'a branch it has not seen will be reported as unknown, though it may be on the remote.',
3007
+ { detail: redactGitToken(message, this.credentials.token()) },
3008
+ );
2963
3009
  }
2964
3010
 
2965
3011
  /** Unified-diff patch for one file across the CR range (rename-aware). */
@@ -195,6 +195,7 @@ export class PendingCommitsService {
195
195
  .set({
196
196
  queuedAt: new Date(),
197
197
  authorEmail: email,
198
+ authorEmailBidx: email,
198
199
  authorName: input.authorName,
199
200
  // Reset transient counters — this is effectively a fresh enqueue.
200
201
  attempts: 0,
@@ -216,6 +217,7 @@ export class PendingCommitsService {
216
217
  branch: input.branch,
217
218
  path: input.path,
218
219
  authorEmail: email,
220
+ authorEmailBidx: email,
219
221
  authorName: input.authorName,
220
222
  });
221
223
  }
@@ -249,11 +251,13 @@ export class PendingCommitsService {
249
251
  ),
250
252
  );
251
253
  if ((rows[0]?.count ?? 0) > 0) return false;
254
+ const email = canonicalEmail(input.authorEmail);
252
255
  await this.db.insert(pendingCommits).values({
253
256
  workspaceId,
254
257
  branch: input.branch,
255
258
  path: input.path,
256
- authorEmail: canonicalEmail(input.authorEmail),
259
+ authorEmail: email,
260
+ authorEmailBidx: email,
257
261
  authorName: input.authorName,
258
262
  });
259
263
  return true;
@@ -41,8 +41,12 @@ export const RECOVERY_BOT_NAME = 'Bevel Recovery Bot';
41
41
  export async function ensureRecoveryBotUser(db: Database): Promise<AuthUser> {
42
42
  const inserted = await db
43
43
  .insert(users)
44
- .values({ email: RECOVERY_BOT_EMAIL, name: RECOVERY_BOT_NAME })
45
- .onConflictDoNothing({ target: users.email })
44
+ .values({
45
+ email: RECOVERY_BOT_EMAIL,
46
+ emailBidx: RECOVERY_BOT_EMAIL,
47
+ name: RECOVERY_BOT_NAME,
48
+ })
49
+ .onConflictDoNothing({ target: users.emailBidx })
46
50
  .returning();
47
51
  if (inserted.length > 0) {
48
52
  const row = inserted[0];
@@ -57,7 +61,7 @@ export async function ensureRecoveryBotUser(db: Database): Promise<AuthUser> {
57
61
  const [row] = await db
58
62
  .select()
59
63
  .from(users)
60
- .where(eq(users.email, RECOVERY_BOT_EMAIL))
64
+ .where(eq(users.emailBidx, RECOVERY_BOT_EMAIL))
61
65
  .limit(1);
62
66
  if (!row) {
63
67
  // Vanishingly unlikely: the row exists for the conflict to fire but is
@@ -247,6 +247,9 @@ describe('ReviewWorkflowService.carryApprovalsForward', () => {
247
247
  prNumber: PR,
248
248
  path: 'Sales/Terms.md',
249
249
  approverEmail: 'carol@bevel.software',
250
+ // The index column of a copied row is written with the address, not
251
+ // with the stored index read back beside it.
252
+ approverEmailBidx: 'carol@bevel.software',
250
253
  approverName: 'Carol',
251
254
  headSha: NEW_HEAD,
252
255
  // The reviewer approved THEN, not now — the merge is not an approval.
@@ -256,6 +259,7 @@ describe('ReviewWorkflowService.carryApprovalsForward', () => {
256
259
  prNumber: PR,
257
260
  path: 'Sales/Pricing.md',
258
261
  approverEmail: 'bob@bevel.software',
262
+ approverEmailBidx: 'bob@bevel.software',
259
263
  approverName: 'Bob',
260
264
  headSha: NEW_HEAD,
261
265
  approvedAt: APPROVED_AT,
@@ -1,4 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
+ import type { SQL } from 'drizzle-orm';
3
+ import { PgDialect } from 'drizzle-orm/pg-core';
2
4
  import { ReviewWorkflowService } from '../review-workflow.service.js';
3
5
  import type { ApprovalTx } from '../review-workflow.interface.js';
4
6
 
@@ -34,6 +36,8 @@ function harness() {
34
36
  const locked: unknown[][] = [];
35
37
  const lockSql: string[] = [];
36
38
  const sets: unknown[] = [];
39
+ /** The condition each rewrite was limited to, as the SQL and values it sends. */
40
+ const wheres: { sql: string; params: unknown[] }[] = [];
37
41
  const tx = {
38
42
  execute: async (statement: { queryChunks?: unknown[] }) => {
39
43
  const text = statementText(statement);
@@ -48,8 +52,9 @@ function harness() {
48
52
  },
49
53
  update: (table: unknown) => ({
50
54
  set: (values: unknown) => ({
51
- where: async () => {
55
+ where: async (condition: SQL) => {
52
56
  sets.push(values);
57
+ wheres.push(new PgDialect().sqlToQuery(condition));
53
58
  order.push(`update:${tableOf(table)}`);
54
59
  },
55
60
  }),
@@ -58,7 +63,7 @@ function harness() {
58
63
  // `eraseApprover` touches nothing but the transaction it is given, so the
59
64
  // service's own collaborators are never reached.
60
65
  const svc = new ReviewWorkflowService({} as never, {} as never, {} as never, {} as never);
61
- return { svc, tx: tx as unknown as ApprovalTx, order, locked, lockSql, sets };
66
+ return { svc, tx: tx as unknown as ApprovalTx, order, locked, lockSql, sets, wheres };
62
67
  }
63
68
 
64
69
  describe('ReviewWorkflowService.eraseApprover', () => {
@@ -79,6 +84,23 @@ describe('ReviewWorkflowService.eraseApprover', () => {
79
84
  expect(h.lockSql[0]).toContain('pg_advisory_xact_lock');
80
85
  expect(h.lockSql[0]).not.toMatch(/pg_advisory_lock\b/);
81
86
  // And the rewrite is to the placeholder identity it was given.
82
- expect(h.sets).toEqual([{ approverEmail: 'deleted-1@erased.invalid', approverName: 'Deleted user' }]);
87
+ // The blind index is rewritten with the email: nothing keyed to the
88
+ // erased address may survive, and the email itself is ciphertext. The
89
+ // index column is written with the address; the handle indexes it.
90
+ expect(h.sets).toEqual([
91
+ {
92
+ approverEmail: 'deleted-1@erased.invalid',
93
+ approverEmailBidx: 'deleted-1@erased.invalid',
94
+ approverName: 'Deleted user',
95
+ },
96
+ ]);
97
+ // WHICH rows: the ones whose blind index is the erased address's. The
98
+ // address column holds randomized ciphertext, so a match on it finds
99
+ // nothing — the rewrite would report success having rewritten no row, and
100
+ // every approval of the erased person would keep their name.
101
+ expect(h.wheres).toHaveLength(1);
102
+ expect(h.wheres[0]!.sql).toMatch(/"approver_email_bidx" = \$1$/);
103
+ expect(h.wheres[0]!.sql).not.toMatch(/"approver_email" =/);
104
+ expect(JSON.stringify(h.wheres[0]!.params)).toContain('bob@bevel.software');
83
105
  });
84
106
  });
@@ -343,6 +343,7 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
343
343
  .values({
344
344
  prNumber,
345
345
  authorEmail: canonicalEmail(user.email),
346
+ authorEmailBidx: user.email,
346
347
  authorName: user.name,
347
348
  path: input.path ?? null,
348
349
  line: input.line ?? null,
@@ -600,6 +601,7 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
600
601
  prNumber,
601
602
  path,
602
603
  approverEmail: callerEmail,
604
+ approverEmailBidx: callerEmail,
603
605
  approverName: user.name,
604
606
  headSha,
605
607
  })
@@ -625,10 +627,13 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
625
627
  erased: { email: string; name: string },
626
628
  ): Promise<void> {
627
629
  await takeEveryApprovalLock(tx);
630
+ // Matched via the blind index — the email column is randomized
631
+ // ciphertext — and the index is rewritten to the placeholder's too, so no
632
+ // value keyed to the erased address survives.
628
633
  await tx
629
634
  .update(prFileApprovals)
630
- .set({ approverEmail: erased.email, approverName: erased.name })
631
- .where(eq(prFileApprovals.approverEmail, email));
635
+ .set({ approverEmail: erased.email, approverEmailBidx: erased.email, approverName: erased.name })
636
+ .where(eq(prFileApprovals.approverEmailBidx, email));
632
637
  }
633
638
 
634
639
  /**
@@ -714,6 +719,7 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
714
719
  .values({
715
720
  prNumber,
716
721
  triggeredByEmail,
722
+ triggeredByEmailBidx: triggeredByEmail,
717
723
  triggeredByName: user.name,
718
724
  headShaAtMerge: headSha,
719
725
  mergeMethod: MERGE_METHOD,
@@ -930,6 +936,9 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
930
936
  prNumber,
931
937
  path: r.path,
932
938
  approverEmail: r.approverEmail,
939
+ // The address, not the stored index read back beside it: an index
940
+ // column is written with what it is the index of.
941
+ approverEmailBidx: r.approverEmail,
933
942
  approverName: r.approverName,
934
943
  headSha: toHeadSha,
935
944
  approvedAt: r.approvedAt,
@@ -1013,7 +1022,7 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
1013
1022
  and(
1014
1023
  eq(prFileApprovals.prNumber, prNumber),
1015
1024
  eq(prFileApprovals.path, path),
1016
- eq(prFileApprovals.approverEmail, callerEmail),
1025
+ eq(prFileApprovals.approverEmailBidx, callerEmail),
1017
1026
  ),
1018
1027
  );
1019
1028
  });
@@ -1770,7 +1770,7 @@ export class WorkflowService implements IWorkflowService {
1770
1770
  .from(changeRequests)
1771
1771
  .where(
1772
1772
  and(
1773
- eq(changeRequests.authorEmail, email),
1773
+ eq(changeRequests.authorEmailBidx, email),
1774
1774
  eq(changeRequests.sourceBranch, sourceBranch),
1775
1775
  ne(changeRequests.state, 'open'),
1776
1776
  ),
@@ -1910,6 +1910,7 @@ export class WorkflowService implements IWorkflowService {
1910
1910
  title: input.title.trim(),
1911
1911
  body,
1912
1912
  authorEmail: canonicalEmail(user.email),
1913
+ authorEmailBidx: user.email,
1913
1914
  authorName: user.name,
1914
1915
  })
1915
1916
  .returning({ number: changeRequests.number });
@@ -3577,6 +3578,7 @@ export class WorkflowService implements IWorkflowService {
3577
3578
  applyFailureKind: failure.kind,
3578
3579
  applyFailedAt: at,
3579
3580
  applyFailedByName: user.name,
3581
+ applyFailedByEmailBidx: user.email,
3580
3582
  })
3581
3583
  // The write itself refuses to go backwards: the attempt map above is an
3582
3584
  // early exit within this process, but an older UPDATE still in flight (or
@@ -3637,6 +3639,7 @@ export class WorkflowService implements IWorkflowService {
3637
3639
  applyFailureConflicts: null,
3638
3640
  applyFailedAt: null,
3639
3641
  applyFailedByName: null,
3642
+ applyFailedByEmailBidx: null,
3640
3643
  applyFailureKind: null,
3641
3644
  })
3642
3645
  .where(